diff --git a/.gitignore b/.gitignore index 16a960e..432b23e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ xcuserdata/ *.xcresult build/ .swiftpm/ +.runtime/ diff --git a/Sources/App/AuthStore.swift b/Sources/App/AuthStore.swift index 6ea56e3..625b60e 100644 --- a/Sources/App/AuthStore.swift +++ b/Sources/App/AuthStore.swift @@ -77,7 +77,7 @@ final class AuthStore: ObservableObject { user = response.user } catch { guard current == generation else { return } - self.error = error.localizedDescription + self.error = ConnectionFailure.message(error, server: config.baseURL) } guard current == generation else { return } isBusy = false diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift index 9b53dd2..56fc9d6 100644 --- a/Sources/Features/Conversation/ConversationView.swift +++ b/Sources/Features/Conversation/ConversationView.swift @@ -219,6 +219,7 @@ private struct ConversationContent: View { ScrollView { VStack(alignment: .leading, spacing: Nearlight.s24) { LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode)) + .frame(maxWidth: .infinity) .padding(.top, Nearlight.s32) VStack(alignment: .leading, spacing: Nearlight.s12) { Text("A moment with Hope.") @@ -248,9 +249,9 @@ private struct ConversationContent: View { // MARK: Status strip (active conversation) private var statusStrip: some View { - HStack(spacing: Nearlight.s12) { + VStack(spacing: Nearlight.s12) { LightwellView(phase: lightwellPhase(session: session, conversation: conversation, textMode: settings.textMode), compact: true) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .center, spacing: 2) { Text(statusTitle) .font(NearlightFont.control(typeSize)) .foregroundStyle(Nearlight.ink) @@ -261,11 +262,12 @@ private struct ConversationContent: View { .foregroundStyle(Nearlight.quiet) } } - Spacer(minLength: 0) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } + .frame(maxWidth: .infinity) .padding(.horizontal, Nearlight.s20) .padding(.vertical, Nearlight.s12) - .frame(minHeight: 60, alignment: .leading) .background(Nearlight.ground) } diff --git a/Sources/Features/Conversation/LightwellView.swift b/Sources/Features/Conversation/LightwellView.swift index 6d1d45b..8c2ade6 100644 --- a/Sources/Features/Conversation/LightwellView.swift +++ b/Sources/Features/Conversation/LightwellView.swift @@ -1,151 +1,125 @@ import SwiftUI -import UIKit -/// The Nearlight signature element: an upright, softly lit aperture. -/// -/// Two capsules in a ZStack — no assets, shaders, blur, or particles. The -/// inner capsule carries a vertical Rosewood gradient; its geometry encodes -/// conversation state and audio level. Noninteractive and accessibility -/// hidden: the companion status text carries meaning. +/// Hope's square LED presence, matching the web's 21 × 21 dot matrix. +/// One Canvas replaces hundreds of subviews. The adjacent status text is the +/// single accessible description; individual LEDs are never focusable. struct LightwellView: View { - /// Lightwell presentation state, mapped from session/conversation models. enum Phase: Equatable { - case idle - case connecting - case waiting + case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput case listening(level: Double) - case thinking case speaking(level: Double) - case tearingDown - case failed - /// Input never drives the light (muted or Type mode). - case readyNoInput } let phase: Phase - /// Compact (24×36) in the conversation status strip; full (64×96) on welcome. - var compact: Bool = false + var compact = false @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Frame dimensions never animate; inner dimensions multiply by 0.375 - /// in the compact frame. - private var frameSize: CGSize { - compact ? CGSize(width: 24, height: 36) : CGSize(width: 64, height: 96) - } - private var baseInner: CGSize { - let base = CGSize(width: 16, height: 40) - return compact - ? CGSize(width: base.width * 0.375, height: base.height * 0.375) - : base - } - @Environment(\.colorSchemeContrast) private var contrast + @Environment(\.scenePhase) private var scenePhase + @Environment(\.dynamicTypeSize) private var typeSize - var body: some View { - Group { - if pulseWanted { - // Isolated TimelineView: ticks only while pending states are - // showing; pauses when offscreen/inactive automatically. - TimelineView(.periodic(from: .now, by: 0.05)) { context in - lightwell(pulse: pulseValue(at: context.date)) - } - } else { - lightwell(pulse: 0) - } - } + private var side: CGFloat { + if compact { return typeSize.isAccessibilitySize ? 88 : 136 } + return typeSize.isAccessibilitySize ? 160 : 220 } - private func lightwell(pulse: Double) -> some View { - let inner = innerSize - return ZStack { - Capsule() - .fill(Nearlight.surface) - Capsule() - .fill(innerFill) - .frame(width: inner.width, height: inner.height) - .opacity(overallOpacity(pulse: pulse)) - } - .frame(width: frameSize.width, height: frameSize.height) - .overlay( - Capsule().strokeBorder( - contrast == .increased ? Nearlight.rosewood : Nearlight.quiet, - lineWidth: 1 - ) - ) - .accessibilityHidden(true) - .animation(reduceMotion ? nil : Nearlight.settle, value: innerSize) - } - - /// Increase Contrast promotes the gradient to solid Rosewood. - private var innerFill: AnyShapeStyle { - if contrast == .increased { - return AnyShapeStyle(Nearlight.rosewood) - } - return AnyShapeStyle( - LinearGradient( - stops: [ - .init(color: Nearlight.rosewood.opacity(0.35), location: 0), - .init(color: Nearlight.rosewood.opacity(1), location: 0.5), - .init(color: Nearlight.rosewood.opacity(0.55), location: 1), - ], - startPoint: .top, endPoint: .bottom - ) - ) - } - - /// Whether a pulse TimelineView should run for this phase. - private var pulseWanted: Bool { + private var animated: Bool { + guard !reduceMotion, scenePhase == .active else { return false } switch phase { - case .connecting, .waiting, .thinking: return !reduceMotion + case .connecting, .waiting, .thinking, .listening, .speaking: return true default: return false } } - /// Triangle wave 0→1→0 over the 2.4s cycle from the timeline date. - private func pulseValue(at date: Date) -> Double { - let t = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 2.4 - return t < 0.5 ? t * 2 : (1 - t) * 2 - } - - /// Inner capsule geometry per spec table. `a` is the smoothed level. - /// Reduce Motion: static 16×40 (idle/listening) and 16×48 (pending/ - /// thinking/speaking) with no level-driven resizing. - private var innerSize: CGSize { - let base = baseInner - if reduceMotion { - switch phase { - case .waiting, .thinking, .speaking: - return CGSize(width: base.width, height: base.height + (compact ? 3 : 8)) - default: - return base + var body: some View { + Group { + if animated { + TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in + matrix(time: timeline.date.timeIntervalSinceReferenceDate) + } + } else { + matrix(time: 0) } } + .frame(width: side, height: side) + .background(Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock)) + .overlay { + RoundedRectangle(cornerRadius: Nearlight.radiusDock) + .strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1) + } + .accessibilityHidden(true) + } + + private var tint: Color { switch phase { - case .listening(let a): - let level = min(max(a, 0), 1) - return CGSize(width: base.width + (compact ? 3 : 8) * level, - height: base.height + (compact ? 6 : 16) * level) - case .speaking(let a): - let level = min(max(a, 0), 1) - return CGSize(width: base.width + (compact ? 4.5 : 12) * level, - height: base.height + (compact ? 9 : 24) * level) - case .waiting, .thinking: - // Pulse drives opacity; height steps up one band. - return CGSize(width: base.width, height: base.height + (compact ? 3 : 8)) - case .connecting, .idle, .failed, .tearingDown, .readyNoInput: - return base + case .failed: return Nearlight.alert + case .idle, .tearingDown, .readyNoInput: return Nearlight.quiet + default: return Nearlight.rosewood } } - private func overallOpacity(pulse: Double) -> Double { - switch phase { - case .connecting, .waiting: - return 0.45 + 0.45 * pulse - case .thinking: - return 0.65 + 0.35 * pulse - case .idle, .failed, .tearingDown: - return 0.45 - case .listening, .speaking, .readyNoInput: - return 1 + private func matrix(time: Double) -> some View { + Canvas { context, size in + let spacing = (min(size.width, size.height) - 28) / 20 + for row in 0..<21 { + for column in 0..<21 { + let dot = Self.dot(row: row, column: column, phase: phase, + time: time, reduceMotion: reduceMotion) + let diameter = max(1.6, spacing * 0.28) * dot.scale + let center = CGPoint(x: 14 + CGFloat(column) * spacing, + y: 14 + CGFloat(row) * spacing) + let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2, + width: diameter, height: diameter) + // Increased contrast brightens the whole lattice; state is + // encoded in its pattern and adjacent text, never color alone. + let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity + context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity))) + } + } } } + + struct Dot: Equatable { + let opacity: Double + let scale: Double + } + + /// Deterministic sampling is shared with offline tests. Reduce Motion freezes + /// waves and signal-driven geometry, while keeping each state's pattern clear. + static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot { + let x = Double(column - 10) / 10.5 + let y = Double(row - 10) / 10.5 + let radius = sqrt(x * x + y * y) + let t = reduceMotion ? 0 : time + let baseline = max(0.08, 0.20 - radius * 0.075) + let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233) + var brightness = baseline + var scale = 1.0 + switch phase { + case .idle, .tearingDown: + break + case .readyNoInput: + brightness += 0.28 * exp(-radius * radius * 3) + case .failed: + // A stable diagonal cross rather than an animated alarm. + if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 } + case .connecting, .waiting: + let ring = exp(-pow((radius - 0.53) * 7, 2)) + let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2) + brightness += ring * sweep * 0.65 + case .thinking: + let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5 + let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2) + brightness += band * 0.65 + case .listening(let raw), .speaking(let raw): + let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0) + let speaking: Bool + if case .speaking = phase { speaking = true } else { speaking = false } + let signal = min(1, level * 1.3) + let bloom = exp(-radius * radius * (3.0 - signal * 1.5)) + let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset) + brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65) + scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal + } + return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale) + } } diff --git a/Sources/Features/Conversation/LoginView.swift b/Sources/Features/Conversation/LoginView.swift index ef41004..7e09fa9 100644 --- a/Sources/Features/Conversation/LoginView.swift +++ b/Sources/Features/Conversation/LoginView.swift @@ -38,8 +38,13 @@ struct LoginView: View { } }.disabled(auth.isBusy) if let serverError { Text(serverError).foregroundStyle(.red) } - Text("Connections use HTTPS and secure WebSockets. A private server certificate may require an installed, trusted CA on this iPhone.") + Text("Connections use HTTPS and secure WebSockets. A private server certificate must be trusted by this iPhone for conversations.") .font(.footnote).foregroundStyle(.secondary) + 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(.secondary) + } } } .navigationTitle("Hope") diff --git a/Sources/Networking/AuthClient.swift b/Sources/Networking/AuthClient.swift index b2778ed..006b3ff 100644 --- a/Sources/Networking/AuthClient.swift +++ b/Sources/Networking/AuthClient.swift @@ -32,6 +32,17 @@ struct AuthLoginResponse: Decodable { } } +enum ConnectionFailure { + static func message(_ error: Error, server: URL) -> String { + let ns = error as NSError + let certificateCodes: Set = [-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 diff --git a/Tests/AuthTests.swift b/Tests/AuthTests.swift index f78e347..cc3242b 100644 --- a/Tests/AuthTests.swift +++ b/Tests/AuthTests.swift @@ -28,6 +28,14 @@ struct AuthTests { config.protocolClasses = [AuthURLProtocol.self] return URLSession(configuration: config) } + @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") diff --git a/Tests/ConversationStateTests.swift b/Tests/ConversationStateTests.swift index 8072e39..cd59c31 100644 --- a/Tests/ConversationStateTests.swift +++ b/Tests/ConversationStateTests.swift @@ -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 diff --git a/Tests/TLSLiveTests.swift b/Tests/TLSLiveTests.swift new file mode 100644 index 0000000..d74b734 --- /dev/null +++ b/Tests/TLSLiveTests.swift @@ -0,0 +1,31 @@ +import Foundation +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 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) + try await client.logout(token: login.token) + print("HOPE_TLS_PROBE native login/me/logout completed") + } +} diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj index fb6ab54..a212877 100644 --- a/Voice.xcodeproj/project.pbxproj +++ b/Voice.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 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 */; }; 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 262B5A712708421C35ED6AC0 /* AuthTests.swift */; }; 302BEC55CA9A10E49C307977 /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5683993A1DE677121FFA1F3 /* ServerConfig.swift */; }; @@ -90,6 +91,7 @@ D9B40076934C28BA4B92D808 /* TLSPinning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSPinning.swift; sourceTree = ""; }; DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.swift; sourceTree = ""; }; E85BACA267ADD462306C9920 /* TaskPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskPanelView.swift; sourceTree = ""; }; + EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TLSLiveTests.swift; sourceTree = ""; }; EB58EEF9490F81FB2E1CE31C /* VoiceSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceSession.swift; sourceTree = ""; }; F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = ""; }; FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = ""; }; @@ -211,6 +213,7 @@ 262B5A712708421C35ED6AC0 /* AuthTests.swift */, BDD0AA8B0E9B45A325D1E5E7 /* ConversationStateTests.swift */, 20A82F8996DEA166C94A7956 /* ServerReplacementCoordinatorTests.swift */, + EA2918A7B29AB6EB033E707C /* TLSLiveTests.swift */, FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */, ); path = Tests; @@ -403,6 +406,7 @@ 2D1633BB6B8C3E7938A46A42 /* AuthTests.swift in Sources */, FC696C6E74057D4C93E6DA67 /* ConversationStateTests.swift in Sources */, BCAB602C722D74F6C96C5E36 /* ServerReplacementCoordinatorTests.swift in Sources */, + 22EA06AD769759D2223D515F /* TLSLiveTests.swift in Sources */, 1813A1CB0921D04FCAEC92D0 /* TranscriptDedupeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/docs/ENGINEERING_LEARNINGS.md b/docs/ENGINEERING_LEARNINGS.md index 6dfbce7..7e771f8 100644 --- a/docs/ENGINEERING_LEARNINGS.md +++ b/docs/ENGINEERING_LEARNINGS.md @@ -29,6 +29,23 @@ Read [PROJECT_STATUS.md](PROJECT_STATUS.md) for the current product scope. 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. +- 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 diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index b39c5bb..0fed076 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -70,6 +70,43 @@ 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). Real WSS/login after profile trust and +physical speech remain user qualification. + ## Current direction Keep **Qwen 2.5**. The user explicitly canceled the earlier request to switch to