Archived
feat: center square Hope presence and guide secure certificate setup
This commit is contained in:
1 parent
6f2659421c
commit
15c6389ffb
12 files changed
+252
-129
No files matched your search
@@ -4,3 +4,4 @@ xcuserdata/
|
||||
*.xcresult
|
||||
build/
|
||||
.swiftpm/
|
||||
.runtime/
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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<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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 = "<group>"; };
|
||||
DAC751BFB00B04C73D19C714 /* AuthClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthClient.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>"; };
|
||||
F5683993A1DE677121FFA1F3 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||
FAEF89B2DEEE68F7168A8014 /* TranscriptDedupeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TranscriptDedupeTests.swift; sourceTree = "<group>"; };
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user