fix: close Nearlight release blockers

This commit is contained in:
snowspeeder committed 2026-09-12 10:13:14 -04:00
1 parent 5577162594
commit b7cec80a99
7 files changed
+307 -176

No files matched your search

@@ -1,3 +1,4 @@
import AVFoundation
import SwiftUI import SwiftUI
/// Owns one session across all tabs. Changing tabs never ends a conversation. /// Owns one session across all tabs. Changing tabs never ends a conversation.
@@ -7,7 +8,6 @@ struct ConversationView: View {
@State private var activeServerURL: String @State private var activeServerURL: String
@State private var pendingServerURL: String? @State private var pendingServerURL: String?
@State private var isReplacingSession = false @State private var isReplacingSession = false
@State private var sessionID = UUID()
@State private var selectedTab = 0 @State private var selectedTab = 0
init(settings: SettingsStore) { init(settings: SettingsStore) {
@@ -28,6 +28,15 @@ struct ConversationView: View {
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.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() 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 #endif
return session return session
} }
@@ -44,7 +53,6 @@ struct ConversationView: View {
ActivityTab(taskStore: session.taskStore, settings: settings, session: session) ActivityTab(taskStore: session.taskStore, settings: settings, session: session)
.tag(1) .tag(1)
} }
.id(sessionID)
.tint(Nearlight.rosewood) .tint(Nearlight.rosewood)
.onChange(of: settings.textMode) { _, enabled in .onChange(of: settings.textMode) { _, enabled in
Task { await session.applyTextMode(enabled) } Task { await session.applyTextMode(enabled) }
@@ -77,7 +85,6 @@ struct ConversationView: View {
await session.disconnect() await session.disconnect()
activeServerURL = next activeServerURL = next
session = Self.makeSession(next, settings: settings) session = Self.makeSession(next, settings: settings)
sessionID = UUID()
} }
isReplacingSession = false isReplacingSession = false
} }
@@ -290,10 +297,11 @@ private struct ConversationContent: View {
} }
private var micDenied: Bool { private var micDenied: Bool {
// Only the explicit permission-denied wording from enableMicrophone guard session.state == .ready, let micError = session.micError else {
// counts; other mic errors keep their real message without a return false
// Settings deep link. }
session.state == .ready && (session.micError?.lowercased().contains("denied") ?? false) return AVAudioApplication.shared.recordPermission == .denied
|| micError.localizedCaseInsensitiveContains("denied")
} }
private var failureTitle: String { private var failureTitle: String {
@@ -543,7 +551,7 @@ private struct ConversationContent: View {
if case .failed(let message) = session.state { return message } if case .failed(let message) = session.state { return message }
return nil return nil
} }
private var failureNotice: String? { failureMessage } private var failureNotice: String? { session.micError ?? failureMessage }
private var statusTitle: String { private var statusTitle: String {
switch session.state { switch session.state {
@@ -8,13 +8,22 @@ struct TranscriptListView: View {
@State private var confirmClear = false @State private var confirmClear = false
@State private var followingLatest = true @State private var followingLatest = true
@State private var selectionActive = false @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(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize @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 /// Auto-follow eligibility: never scroll while the user is selecting
/// text or reading history. /// text or reading history.
private var isFollowingEligible: Bool { private var isFollowingEligible: Bool {
followingLatest && !selectionActive 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*"# 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*"#
@@ -44,13 +53,29 @@ struct TranscriptListView: View {
.padding(.top, Nearlight.s24) .padding(.top, Nearlight.s24)
} }
ForEach(events) { event in transcriptRow(event) } ForEach(events) { event in transcriptRow(event) }
Color.clear.frame(height: 1).id("latest") 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(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16) .padding(.top, Nearlight.s16)
.padding(.bottom, Nearlight.s20) .padding(.bottom, Nearlight.s20)
.frame(minHeight: 200, alignment: .topLeading) .frame(minHeight: 200, alignment: .topLeading)
} }
.coordinateSpace(name: "transcriptScroll")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: TranscriptViewportHeightKey.self,
value: geometry.size.height
)
}
}
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.top) .defaultScrollAnchor(.top)
.simultaneousGesture(DragGesture().onChanged { _ in .simultaneousGesture(DragGesture().onChanged { _ in
@@ -61,15 +86,32 @@ struct TranscriptListView: View {
guard isFollowingEligible else { return } guard isFollowingEligible else { return }
proxy.scrollTo("latest", anchor: .bottom) proxy.scrollTo("latest", anchor: .bottom)
} }
.onChange(of: events.count) { _, _ in .onChange(of: events.count) { _, count in
guard isFollowingEligible else { return } if count == 0 {
selectionActive = false
followingLatest = true
} else if isFollowingEligible {
proxy.scrollTo("latest", anchor: .bottom) proxy.scrollTo("latest", anchor: .bottom)
} }
}
.onChange(of: selectionActive) { _, active in .onChange(of: selectionActive) { _, active in
// Beginning text selection suspends auto-follow so the // Beginning text selection suspends auto-follow so the
// user's reading position is never disturbed. // user's reading position is never disturbed.
if active { followingLatest = false } 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 { if !followingLatest && !events.isEmpty {
HStack { HStack {
Spacer() Spacer()
@@ -77,6 +119,7 @@ struct TranscriptListView: View {
// Explicit Latest restores full eligibility, // Explicit Latest restores full eligibility,
// clearing any stale selection suspension. // clearing any stale selection suspension.
selectionActive = false selectionActive = false
focusedEventID = nil
followingLatest = true followingLatest = true
withAnimation(reduceMotion ? nil : Nearlight.standard) { withAnimation(reduceMotion ? nil : Nearlight.standard) {
proxy.scrollTo("latest", anchor: .bottom) proxy.scrollTo("latest", anchor: .bottom)
@@ -104,6 +147,17 @@ struct TranscriptListView: View {
} }
} }
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 { private func transcriptRow(_ event: TranscriptEvent) -> some View {
let isUser = event.role == .user let isUser = event.role == .user
let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression) let text = event.text.replacingOccurrences(of: Self.deliveryMarkers, with: "", options: .regularExpression)
@@ -141,6 +195,7 @@ struct TranscriptListView: View {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
} }
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityFocused($focusedEventID, equals: event.id)
.id(event.id) .id(event.id)
} }
@@ -163,3 +218,19 @@ struct TranscriptListView: View {
.monospacedDigit() .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()
}
}
+10 -19
View File
@@ -12,12 +12,9 @@ struct SettingsView: View {
@State private var urlText: String = "" @State private var urlText: String = ""
@State private var urlError: String? @State private var urlError: String?
@State private var showVoicePicker = false @State private var showVoicePicker = false
@State private var urlJustSaved = false @State private var savedURLConfirmation: String?
@State private var showModelPicker = false @State private var showModelPicker = false
@FocusState private var urlFocused: Bool @FocusState private var urlFocused: Bool
@State private var applyingTrim = false
/// Guards the success-trim against the edit observer (N3).
init(settings: SettingsStore, session: VoiceSession) { init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings self.settings = settings
self.session = session self.session = session
@@ -73,17 +70,15 @@ struct SettingsView: View {
.accessibilityLabel("Server URL") .accessibilityLabel("Server URL")
.focused($urlFocused) .focused($urlFocused)
.onAppear { urlText = settings.serverURL } .onAppear { urlText = settings.serverURL }
.onChange(of: urlText) { oldValue, newValue in .onChange(of: urlText) { _, newValue in
// A programmatic normalization on successful Save must // Keep confirmation across the asynchronous onChange
// not erase its own confirmation; only user edits do. // caused by successful whitespace normalization.
guard applyingTrim == false else { return } guard newValue != savedURLConfirmation else { return }
if newValue != oldValue { savedURLConfirmation = nil
urlJustSaved = false
urlError = nil urlError = nil
} }
}
.overlay(alignment: .trailing) { .overlay(alignment: .trailing) {
if urlJustSaved { if savedURLConfirmation != nil {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
.foregroundStyle(Nearlight.quiet) .foregroundStyle(Nearlight.quiet)
.padding(.trailing, 8) .padding(.trailing, 8)
@@ -95,7 +90,7 @@ struct SettingsView: View {
.font(.footnote) .font(.footnote)
.foregroundStyle(Nearlight.alert) .foregroundStyle(Nearlight.alert)
.accessibilityIdentifier("settings.server.error") .accessibilityIdentifier("settings.server.error")
} else if urlJustSaved { } else if savedURLConfirmation != nil {
Text("Server address saved.") Text("Server address saved.")
.font(.footnote) .font(.footnote)
.foregroundStyle(Nearlight.quiet) .foregroundStyle(Nearlight.quiet)
@@ -135,7 +130,7 @@ struct SettingsView: View {
/// left untouched so the active session keeps using its original URL. /// left untouched so the active session keeps using its original URL.
/// Guarded: only applies when the session is idle (no active call). /// Guarded: only applies when the session is idle (no active call).
private func applyServerURL() { private func applyServerURL() {
urlJustSaved = false savedURLConfirmation = nil
guard canApplyServerURL else { guard canApplyServerURL else {
urlError = "End the conversation before changing servers." urlError = "End the conversation before changing servers."
return return
@@ -148,13 +143,9 @@ struct SettingsView: View {
switch ServerConfig.parse(trimmed) { switch ServerConfig.parse(trimmed) {
case .success: case .success:
settings.serverURL = trimmed settings.serverURL = trimmed
if trimmed != urlText {
applyingTrim = true
urlText = trimmed urlText = trimmed
applyingTrim = false
}
urlError = nil urlError = nil
urlJustSaved = true savedURLConfirmation = trimmed
urlFocused = false urlFocused = false
case .failure(let error): case .failure(let error):
urlError = error.localizedDescription urlError = error.localizedDescription
+36 -3
View File
@@ -36,18 +36,36 @@ final class VoiceUITests: XCTestCase {
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.exists) XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.exists)
let server = app.textFields["Server URL"] let server = app.textFields["Server URL"]
server.tap() server.tap()
let old = server.value as? String ?? "" let original = server.value as? String ?? ""
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: old.count) + "http://example.com") server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: original.count) + "http://example.com")
app.buttons["Save server address"].tap() app.buttons["Save server address"].tap()
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'must use https'")).firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'must use https'")).firstMatch.waitForExistence(timeout: 5))
let invalid = server.value as? String ?? ""
server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: invalid.count) + " https://127.0.0.1:1 ")
app.buttons["Save server address"].tap()
XCTAssertEqual(server.value as? String, "https://127.0.0.1:1")
XCTAssertTrue(app.staticTexts["Server address saved."].waitForExistence(timeout: 5))
let unchanged = server.value as? String ?? ""
server.tap()
server.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: unchanged.count) + " https://127.0.0.1:2 ")
app.buttons["Save server address"].tap()
Thread.sleep(forTimeInterval: 2)
XCTAssertTrue(app.navigationBars["Settings"].exists)
XCTAssertTrue(app.staticTexts["Server address saved."].exists)
XCTAssertEqual(server.value as? String, "https://127.0.0.1:2")
app.terminate() app.terminate()
app.launchArguments = ["--ui-testing"] app.launchArguments = ["--ui-testing"]
app.launch() app.launch()
app.buttons["Settings"].tap() app.buttons["Settings"].tap()
XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons.matching(NSPredicate(format: "label CONTAINS 'Speaking voice' AND label CONTAINS 'Jenny'")).firstMatch.waitForExistence(timeout: 5))
XCTAssertEqual(app.textFields["Server URL"].value as? String, "https://127.0.0.1:1") XCTAssertEqual(app.textFields["Server URL"].value as? String, "https://127.0.0.1:2")
} }
func testTranscriptClearConfirmationAndActivityDetails() { func testTranscriptClearConfirmationAndActivityDetails() {
app.launchArguments.append("--ui-fixtures") app.launchArguments.append("--ui-fixtures")
app.launch() app.launch()
@@ -69,6 +87,21 @@ final class VoiceUITests: XCTestCase {
capture("Activity details") 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() { func testLargeTypeNavigation() {
app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"] app.launchArguments += ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"]
app.launch() app.launch()
+1 -1
View File
@@ -12,7 +12,7 @@ ensure the server cert pin is current (see README — re-export after any server
## 1. Mic input with real speech ## 1. Mic input with real speech
- [ ] Start → agent joins → mic enables **without** `-4010`. - [ ] 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. - [ ] Stop speaking, wait >2 s while agent is silent → **thinking** chip appears.
- [ ] Agent replies: state goes to **speaking** (remote audio drives it), TTS audible. - [ ] Agent replies: state goes to **speaking** (remote audio drives it), TTS audible.
- [ ] User transcript finals arrive in the list with correct role/timestamps. - [ ] User transcript finals arrive in the list with correct role/timestamps.
+95 -76
View File
@@ -16,50 +16,66 @@ 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 is untouched. The backend AAC compatibility patch (below) is now irrelevant to
the iOS app but remains deployed. the iOS app but remains deployed.
The UI and playback changes are implemented, and the NAS deployment was verified The **Nearlight** native redesign is implemented across Talk, Activity, Settings,
on September 8. Full app qualification is **not complete**: physical-device tests pickers, task details, and connection diagnostics. Current simulator qualification
and some broader live scenarios remain. This report summarizes prior evidence; is green, and GPT-6 Astra's final blocker re-verification returned **PASS**. The
server health and device availability must be rechecked when resuming. final source also produced a signed generic-iOS build. A physical install and
launch succeeded before the last Nearlight corrections; the paired iPhone is
currently unavailable, so install the final build when it reconnects. Hardware
audio and interruption qualification remains open.
## Completed work ## Completed work
### Native interface and usability ### Native interface and usability
- Reorganized the app into native **Talk, Music, Activity** tabs, sharing one session. - The app now has native **Talk** and **Activity** tabs sharing one session;
(The Music tab was removed again on 2026-09-12 by user decision; the app now has changing tabs never ends a conversation.
**Talk** and **Activity** tabs.) - Implemented Astra's **Nearlight** direction: an adaptive plum/rosewood semantic
- Replaced the large animated orb with a compact waveform/status presentation; palette, restrained rounded typography, shared spacing/radius/motion tokens, and
added a clearer welcome screen, Voice/Type selection before connecting, explicit the compact Lightwell aperture as the conversation state signature.
start/end controls, microphone meter, and recoverable connection errors. - Rebuilt Talk around a clear pre-connection welcome, Voice/Type choice, explicit
- Improved transcript readability, timestamps, live updates, copy/selection, start/end controls, compact active status, microphone meter, persistent Type
follow-latest scrolling, and explicit Cancel/Clear confirmation. draft, and a dock that keeps primary actions reachable.
- Reworked Settings around voice, input mode, model, and connection; server changes - Reworked the transcript as a readable shared record with speaker/time metadata,
require an explicit validated save. Voice selection persists. live partials, copy/selection, explicit destructive confirmation, and measured
- Improved Music library/search navigation, now-playing controls, seek/volume, follow-latest behavior. Restored history stays at the top; manual reading,
empty/loading/error states, and validated station-entry sheet. (Removed 2026-09-12.) selection, and VoiceOver row focus suspend following until **Latest**.
- Improved Activity hierarchy and task detail presentation with an active-task badge. - Made connection and ready-session microphone failures visible without discarding
- Removed forced dark appearance; adopted adaptive system styling, Dynamic Type, the real error. Permission denial exposes an iPhone Settings recovery action;
and larger interaction targets, informed by the Impeccable native-iOS guidance. 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 ### Functional fixes
- Observed nested transcript/state/task objects so asynchronous updates render. - Observed nested transcript, conversation-state, and task objects so asynchronous
- Preserved latest-wins server replacement. updates render.
(All media-related behaviors — media clearing on server switch, standalone media - Preserved latest-wins server replacement while keeping an open Settings
controls/reports, track metadata refresh, pinned-TLS artwork, cached-AAC requests, navigation stack and its success feedback intact.
UTI content-type fix, bounded-range media loader — were removed with the music - Preserved Type-mode microphone privacy, pending-message send gating, clean session
player on 2026-09-12.) 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 ### Tests and tooling
- Installed XcodeBuildMCP and its skills; used it for simulator build/run/test work. - Installed XcodeBuildMCP and its skills; used it for simulator build/run/test work.
- Added the `VoiceUITests` target in `project.yml` and regenerated the Xcode project. - The current combined simulator run passed **50 tests, 0 failures**, with the
- Added isolated DEBUG UI-test preferences and optional fixtures; normal application opt-in live suite skipped once. Five offline UI scenarios cover recovery,
preferences are not reset by those UI-test launch arguments. settings validation/persistence and save feedback, transcript/task flows,
- Added media-control/error/metadata tests and opt-in real-NAS UI tests. (Media tests restored-history reading position, and accessibility-size navigation.
were removed with the music player on 2026-09-12.) - A post-focus-change targeted restored-history UI regression passed independently.
- Live tests retain failure screenshots/UI hierarchy and attempt to stop music - DEBUG UI fixtures use isolated preferences; ordinary application preferences and
they initiated even when an assertion fails. (Music playback test removed 2026-09-12.) 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 ## Backend deployment and cross-repository dependency
@@ -92,57 +108,60 @@ Do not copy credentials or `.env` contents into documentation or commits.
## Verification performed ## Verification performed
Project `Voice.xcodeproj`, scheme `Voice_iOS`, iPhone 17 Pro simulator. Project `Voice.xcodeproj`, scheme `Voice_iOS`, iPhone 17 Pro simulator
`AFDA137E-1657-428E-BF01-E5CD7989772C`.
- 49 unit tests passed (after the 2026-09-12 music-player removal; previously 68 with - Current combined run: **50 passed, 0 failed, 1 live skip** in 104.9 seconds.
media tests); four offline UI tests passed (navigation/error recovery, Result bundle:
settings persistence/validation, transcript/task UI, large text). `test_sim_2026-09-12T14-03-09-058Z_pid95846_2ca9da7e.xcresult`.
- Real NAS tests verified Qwen replies, background task completion, reconnect, - Post-focus-change restored-history regression: **1 passed, 0 failed**.
library loading, advancing playback time, pause, forward-seek control and stop. Result bundle:
(Playback evidence predates the music-player removal; the feature no longer exists `test_sim_2026-09-12T14-05-21-070Z_pid98187_bd3e531a.xcresult`.
in the app.) - A fresh isolated simulator launch showed restored history top-anchored with
- Qwen/task/reconnect passed three consecutive iterations. **Latest** visible; the UI regression then tapped Latest and verified the final
- Backend integration: 16 passed, 7 skipped, 2 deselected. event became visible.
- Backend AAC regression: one passed (conversion, cache reuse, original preservation, - Astra's targeted isolated runtime check verified a padded changed-address save
and byte ranges); deployed range request returned HTTP 206. stays in Settings, normalizes the field, shows its checkmark and confirmation,
- The final bounded-range change was verified by focused playback repetitions, and clears both only after the next user edit. Final verdict: **PASS**.
not a new full combined regression run. - iPhone 14 Pro Max `BF93D35E-0853-5470-A125-6EB0DB3F17AB` was paired, signed,
installed, trusted, and launched successfully. The final source subsequently
produced a signed device build at
`/tmp/voice-ios-device-derived/Build/Products/Debug-iphoneos/Voice.app`.
Free provisioning expires after seven days. The phone was unavailable during
the final correction pass, so that final build still needs installing.
- Historical live NAS runs verified Qwen replies, background task completion, and
reconnect. Backend integration remains 16 passed, 7 skipped, 2 deselected.
Exact result-bundle names and qualifications: [VERIFICATION.md](VERIFICATION.md). Exact historical qualifications: [VERIFICATION.md](VERIFICATION.md). Artifacts
Artifacts live on this Mac under the XcodeBuildMCP workspace, not in Git. live on this Mac under the XcodeBuildMCP workspace, not in Git.
## What remains, in order ## What remains, in order
1. ~~Pair the physical iPhone.~~ **Done 2026-09-12.** iPhone 14 Pro Max 1. **Install the final signed build on the paired iPhone** when it reconnects.
(`BF93D35E-0853-5470-A125-6EB0DB3F17AB`) is paired and available. The current artifact is
2. ~~Build, sign, install and run on that iPhone.~~ **Done 2026-09-12.** Signing `/tmp/voice-ios-device-derived/Build/Products/Debug-iphoneos/Voice.app`; the
team is the free personal team `YJFYRB92NW` (`DEVELOPMENT_TEAM` in earlier successful install predates the last reading-safety, microphone-notice,
`project.yml`; `H8D4WY66F6` is the certificate's individual-ID suffix, not a and Settings-confirmation corrections.
team ID — do not use it). Xcode required a fresh account sign-in, then the 2. **Qualify real audio:** microphone capture, TTS audibility, speaker/headphones,
user tapped Trust under Settings → General → VPN & Device Management before
first launch. App installed and launched via `devicectl`. **Limitation:** free
provisioning expires the app after 7 days; reinstall to re-sign.
3. **Qualify real audio:** microphone capture, TTS audibility, speaker/headphones,
barge-in, Siri/phone interruptions, lock/background behavior, barge-in, Siri/phone interruptions, lock/background behavior,
microphone-permission-denied fallback, and network-loss recovery. Some checks microphone-permission-denied fallback, and network-loss recovery. These require
require the user's speech/listening or a real interruption; simulator state tests the user's speech/listening or a real interruption; simulator state tests do not
do not prove them. Follow [DEVICE_TESTING.md](DEVICE_TESTING.md). prove them. Follow [DEVICE_TESTING.md](DEVICE_TESTING.md).
4. **Broader regression:** rerun the full suite after the music-player removal; 3. **Broader live regression:** exercise slow-network cancellation, network-loss
test slow network/cancellation and server replacement. Exercise voice/model recovery, repeated server replacement, and voice/model selection against the
selection against the live agent. live agent.
5. **Final visual/accessibility pass:** inspect all populated/error screens in light 4. **Manual accessibility/visual qualification:** inspect populated/error screens
and dark mode, large text and small-device layouts; test VoiceOver and focus. in light and dark mode, accessibility text sizes, narrow/landscape layouts, and
The existing large-text navigation test is not a full accessibility audit. a real VoiceOver focus session. Astra accepted the remaining presentation debt
6. **Publish the backend compatibility source safely** in the sibling repository for this personal-app release; that is not proof of exhaustive conformance.
and ensure its normal deployment preserves mounts, certificates and Qwen settings. 5. **Publish the backend compatibility source safely** in the sibling repository
(Lower priority now: the iOS app no longer consumes the media API, but the deployed and ensure its normal deployment preserves mounts, certificates, and Qwen
container still serves it for the web UI.) settings. This is lower priority because the iOS app no longer consumes the
7. Update this report with results and remaining limitations. Do not mark the whole media API, but the deployed container still serves it for the web UI.
app qualified until the outstanding end-to-end gates have evidence.
## Repository handoff ## Repository handoff
The iOS work and this report belong to branch `feat/ios-app`, remote `origin` The iOS work and this report belong to branch `feat/ios-app`, remote `origin`
(`https://git.srmr.xyz/snowspeeder/hope-voice-ios.git`). The requested handoff commit (`https://git.srmr.xyz/snowspeeder/hope-voice-ios.git`). Keep the Nearlight
includes the implementation, tests, deployment notes and startup instructions. implementation, tests, physical-device notes, and this handoff together when
Check Git history/remote status for the final commit and push result. committing. Check Git history and remote status for the final publish result.
+74 -65
View File
@@ -1,77 +1,86 @@
# Verification — 2026-09-08 # Verification — 2026-09-12
## Scope and implementation ## Scope
The requested model migration was canceled: Qwen 2.5 remains configured. Qwen 2.5 remains configured; the canceled subscription-login migration was not
The native redesign separates Talk, Music and Activity; simplifies the welcome revived. The embedded music player was removed by user decision. The current app
and connection controls; supports light/dark appearance and Dynamic Type; is the Astra-designed **Nearlight** native interface with shared Talk and Activity
adds recoverable error states; and gives transcript clearing explicit confirmation. tabs, adaptive semantic styling, the Lightwell conversation indicator, rebuilt
The Impeccable native-iOS guidance informed system controls, hierarchy, readable conversation controls, readable transcripts, actionable failure states, explicit
spacing and touch targets. Settings, transcripts and task updates are observed Settings saves, and revised Activity/detail surfaces.
directly so asynchronous changes render promptly.
Music fixes include standalone control requests, current download metadata, ## Current iOS evidence
visible failures, pinned artwork, server-change cleanup, cached AAC delivery,
and correct AVFoundation content-type identifiers in the pinned resource loader.
## Evidence Project: `Voice.xcodeproj`; scheme: `Voice_iOS`; simulator: iPhone 17 Pro
`AFDA137E-1657-428E-BF01-E5CD7989772C`.
Project: `Voice.xcodeproj`; scheme: `Voice_iOS`; simulator: iPhone 17 Pro. - 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.
- 68 unit tests passed, including standalone media controls, retry error clearing, Result bundles under
download metadata, transcript persistence/deduplication, session replacement,
and interruption state handling.
- Five offline UI tests passed: navigation/offline recovery, voice persistence and
URL validation, music search failure/station validation, transcript confirmation
and task details, and accessibility-size navigation.
- Two live UI tests passed together against the NAS at 17:56 UTC: real Qwen
response, background task completion, disconnect/reconnect, library playback
with advancing media time, pause, forward seek and stop.
- Backend integration run: 16 passed, 7 skipped, 2 deselected. This is not a claim
that the skipped or slow integration cases were verified.
- AAC conversion regression: one passed, checking original-file preservation,
AAC output, cache reuse and HTTP byte ranges. Deployed range request returned 206.
XcodeBuildMCP result bundles are under
`~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/`: `~/Library/Developer/XcodeBuildMCP/workspaces/voice-ios-9eb036fdea81/result-bundles/`:
- Unit/offline UI: `test_sim_2026-09-08T11-48-52-164Z_pid87175_79b1a610.xcresult`. - Full run:
This combined run's two live cases failed after long stalls; its unit/offline `test_sim_2026-09-12T14-03-09-058Z_pid95846_2ca9da7e.xcresult`
results above passed. - Targeted restored-history run:
- Latest live pair: `test_sim_2026-09-08T17-56-51-984Z_pid30949_ec504db2.xcresult`. `test_sim_2026-09-12T14-05-21-070Z_pid98187_bd3e531a.xcresult`
- Earlier live rerun: `test_sim_2026-09-08T17-46-07-841Z_pid30949_bd804a04.xcresult`
failed agent join and advancing playback. Their intermittent cause has not ## Physical-device evidence
been established. The subsequent passing pair did not require a server restart.
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 completed a signed generic-iOS build at
`/tmp/voice-ios-device-derived/Build/Products/Debug-iphoneos/Voice.app`. The
device was unavailable to `devicectl`, so this artifact was not installed. The
successful earlier install therefore predates the last Nearlight
reading-safety, microphone-notice, and save-confirmation corrections.
## 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 ## Not yet qualified
Passing simulator tests do not prove audible speaker/headphone output, microphone - Audible speaker/headphone output, physical microphone capture, barge-in,
capture, barge-in, phone/Siri interruption recovery, background audio, or network phone/Siri interruption recovery, lock/background behavior, and live
loss recovery on hardware. Follow `DEVICE_TESTING.md` with a physical iPhone on network-loss recovery.
the LAN. Navigation at large text sizes is tested, not a complete VoiceOver audit. - A real microphone-permission-denied session. The visibility/recovery branches
Live tests use an existing library track; they do not prove new YouTube downloads are source-reviewed, but no permission was changed during final verification.
or every radio stream format. - 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.
- The final corrected binary on the physical iPhone while it remains unavailable.
## Repeatability follow-up — 18:04 UTC Follow [DEVICE_TESTING.md](DEVICE_TESTING.md) for hardware qualification and
[PROJECT_STATUS.md](PROJECT_STATUS.md) for current priorities and deployment
Three consecutive Qwen/task/reconnect iterations passed. Music passed twice and dependencies.
failed on the third iteration; runtime logs captured AVPlayer's request timeout
while the TLS loader buffered a whole 6 MB response. The loader now delivers
bounded 256 KB ranges to AVPlayer, retaining cancellation and EOF handling.
After that change, **all three consecutive live playback iterations passed**
in about 13 seconds each. This addresses the observed music buffering failure;
the earlier isolated agent-join failure has not been reproduced or explained.
- Reproduction bundle: `test_sim_2026-09-08T18-00-03-993Z_pid30949_49da9d2c.xcresult`.
- Post-fix playback bundle: `test_sim_2026-09-08T18-04-30-014Z_pid30949_df323119.xcresult`.
- Live tests now attach failure screenshots and UI hierarchy, and attempt to stop
test-initiated music during teardown even after an assertion failure.
The initial hardware check found an unavailable iPhone. A later USB check detected
the phone running iOS 26.6.1, explicitly marked **unpaired** by XcodeBuildMCP.
No successful device install or hardware qualification is recorded. See
`PROJECT_STATUS.md` for the current pairing blocker and next steps.
Deployment and rollback are recorded in `DEPLOYMENT.md`. The NAS image includes
the compatibility patch; the sibling backend source still requires its own commit
and safe deployment workflow. See `PROJECT_STATUS.md` for repository handoff scope.