feat: consolidate iOS with API and web preserving history
Rebuild & Deploy / rebuild (push) Failing after 23s
Rebuild & Deploy / rebuild (push) Failing after 23s
This commit is contained in:
commit
d735ee72fa
102 files changed
+13077
-258
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AuthStore: ObservableObject {
|
||||
@Published private(set) var user: AuthUser?
|
||||
@Published private(set) var token: String?
|
||||
@Published private(set) var isBusy = false
|
||||
@Published private(set) var error: String?
|
||||
var disconnectAccount: (() async -> Void)?
|
||||
let config: ServerConfig
|
||||
private let client: AuthClient
|
||||
private let tokens: TokenStore
|
||||
private var generation = 0
|
||||
private var operation: Task<AuthUser, Error>?
|
||||
private var loginOperation: Task<AuthLoginResponse, Error>?
|
||||
let fixture: Bool
|
||||
|
||||
init(config: ServerConfig, client: AuthClient? = nil, tokens: TokenStore = KeychainTokenStore(), fixture: Bool = false) {
|
||||
self.config = config
|
||||
self.client = client ?? AuthClient(config: config)
|
||||
self.tokens = tokens
|
||||
self.fixture = fixture
|
||||
if fixture {
|
||||
user = AuthUser(id: "offline-fixture", username: "fixture", displayName: "Offline fixture", role: "user", status: "ready")
|
||||
token = "offline-fixture-not-a-server-token"
|
||||
}
|
||||
}
|
||||
func restore() async {
|
||||
guard !fixture, !isBusy, user == nil else { return }
|
||||
generation += 1
|
||||
let current = generation
|
||||
isBusy = true
|
||||
do {
|
||||
guard let saved = try tokens.token(origin: config.baseURL.absoluteString) else { isBusy = false; return }
|
||||
let task = Task { try await client.me(token: saved) }
|
||||
operation = task
|
||||
let verified = try await task.value
|
||||
guard current == generation, !Task.isCancelled else { return }
|
||||
guard ["ready", "provisioning"].contains(verified.status) else { throw AuthError.status(403) }
|
||||
user = verified
|
||||
token = saved
|
||||
} catch {
|
||||
guard current == generation else { return }
|
||||
if let authError = error as? AuthError,
|
||||
case .status(let status) = authError, status == 401 || status == 403 {
|
||||
try? tokens.save(nil, origin: config.baseURL.absoluteString)
|
||||
self.error = "Please sign in to continue."
|
||||
} else {
|
||||
// An outage is not revocation. Preserve the origin-scoped bearer
|
||||
// for a subsequent launch/retry, but expose no unverified user UI.
|
||||
self.error = "Could not verify your saved sign-in. Check the connection or sign in again."
|
||||
}
|
||||
}
|
||||
guard current == generation else { return }
|
||||
isBusy = false
|
||||
operation = nil
|
||||
}
|
||||
func login(username: String, password: String) async {
|
||||
guard !isBusy else { return }
|
||||
if fixture {
|
||||
user = AuthUser(id: "offline-" + username, username: username, displayName: username, role: "user", status: "ready")
|
||||
token = "offline-fixture-not-a-server-token"
|
||||
return
|
||||
}
|
||||
generation += 1
|
||||
let current = generation
|
||||
isBusy = true
|
||||
error = nil
|
||||
do {
|
||||
let task = Task { try await client.login(username: username, password: password) }
|
||||
loginOperation = task
|
||||
let response = try await task.value
|
||||
guard current == generation, !Task.isCancelled else { return }
|
||||
guard !response.token.isEmpty, ["ready", "provisioning"].contains(response.user.status), response.expiresAt > Date().timeIntervalSince1970 else { throw AuthError.status(403) }
|
||||
try tokens.save(response.token, origin: config.baseURL.absoluteString)
|
||||
token = response.token
|
||||
user = response.user
|
||||
} catch {
|
||||
guard current == generation else { return }
|
||||
self.error = ConnectionFailure.message(error, server: config.baseURL)
|
||||
}
|
||||
guard current == generation else { return }
|
||||
isBusy = false
|
||||
loginOperation = nil
|
||||
}
|
||||
func logout(disconnect: () async -> Void) async {
|
||||
generation += 1
|
||||
loginOperation?.cancel()
|
||||
loginOperation = nil
|
||||
operation?.cancel()
|
||||
operation = nil
|
||||
let previous = token
|
||||
token = nil // immediately disables bootstrap; account UI stays until transport closes
|
||||
isBusy = true
|
||||
await disconnect()
|
||||
try? tokens.save(nil, origin: config.baseURL.absoluteString)
|
||||
user = nil
|
||||
disconnectAccount = nil
|
||||
error = nil
|
||||
if let previous, !fixture { try? await client.logout(token: previous) }
|
||||
isBusy = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Nearlight design tokens (design spec: local://ui-design-spec.md).
|
||||
///
|
||||
/// Views reference semantic roles; the primitive hex values live only here.
|
||||
/// All custom surfaces are opaque; native system materials are untouched.
|
||||
enum Nearlight {
|
||||
// MARK: Palette (light / dark variants)
|
||||
|
||||
/// Opal screen background: luminous pink-white #FFFDFD / deep plum #201820.
|
||||
static let ground = Color(light: UIColor(red: 255 / 255, green: 253 / 255, blue: 253 / 255, alpha: 1),
|
||||
dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
|
||||
/// Controls, grouped rows, dock: #FFF7FA / #302330.
|
||||
static let surface = Color(light: UIColor(red: 255 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1),
|
||||
dark: UIColor(red: 48 / 255, green: 35 / 255, blue: 48 / 255, alpha: 1))
|
||||
/// The square persona's pale pink stage: #FFF2F7 / #3B2836.
|
||||
static let personaSurface = Color(light: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1),
|
||||
dark: UIColor(red: 59 / 255, green: 40 / 255, blue: 54 / 255, alpha: 1))
|
||||
/// Primary text: readable dark plum #302B40 / #FFF2F7.
|
||||
static let ink = Color(light: UIColor(red: 48 / 255, green: 43 / 255, blue: 64 / 255, alpha: 1),
|
||||
dark: UIColor(red: 255 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1))
|
||||
/// Supporting text, inactive icons: plum #706074 / #CBB6C7.
|
||||
static let quiet = Color(light: UIColor(red: 112 / 255, green: 96 / 255, blue: 116 / 255, alpha: 1),
|
||||
dark: UIColor(red: 203 / 255, green: 182 / 255, blue: 199 / 255, alpha: 1))
|
||||
/// Opal action rose #A64270 / #F0A8C8; retains the existing semantic API.
|
||||
static let rosewood = Color(light: UIColor(red: 166 / 255, green: 66 / 255, blue: 112 / 255, alpha: 1),
|
||||
dark: UIColor(red: 240 / 255, green: 168 / 255, blue: 200 / 255, alpha: 1))
|
||||
/// Errors and destructive actions.
|
||||
static let alert = Color(light: UIColor(red: 0.675, green: 0.208, blue: 0.282, alpha: 1),
|
||||
dark: UIColor(red: 1.0, green: 0.604, blue: 0.675, alpha: 1))
|
||||
|
||||
// MARK: Derived roles
|
||||
|
||||
/// Decorative separator: Ink at 14% over its parent.
|
||||
static var separator: Color { ink.opacity(0.14) }
|
||||
/// Selected wash: Rosewood at 10% over Surface.
|
||||
static var selectedWash: Color { rosewood.opacity(0.10) }
|
||||
/// Error wash: Alert at 8% over Surface.
|
||||
static var errorWash: Color { alert.opacity(0.08) }
|
||||
/// Primary-action label: white on strong rose, deep plum on the dark-mode pink.
|
||||
static let onRosewood = Color(light: .white,
|
||||
dark: UIColor(red: 32 / 255, green: 24 / 255, blue: 32 / 255, alpha: 1))
|
||||
|
||||
// MARK: Spacing scale
|
||||
|
||||
static let s4: CGFloat = 4
|
||||
static let s8: CGFloat = 8
|
||||
static let s12: CGFloat = 12
|
||||
static let s16: CGFloat = 16
|
||||
static let s20: CGFloat = 20
|
||||
static let s24: CGFloat = 24
|
||||
static let s32: CGFloat = 32
|
||||
static let s48: CGFloat = 48
|
||||
|
||||
// MARK: Radius scale
|
||||
|
||||
static let radiusSegment: CGFloat = 8
|
||||
static let radiusControl: CGFloat = 12
|
||||
static let radiusDock: CGFloat = 16
|
||||
|
||||
// MARK: Motion tokens
|
||||
|
||||
/// quick = 0.12s easeOut.
|
||||
static let quick: Animation = .easeOut(duration: 0.12)
|
||||
/// standard = 0.22s timingCurve(0.22, 1, 0.36, 1).
|
||||
static let standard: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.22)
|
||||
/// settle = 0.32s timingCurve(0.22, 1, 0.36, 1).
|
||||
static let settle: Animation = .timingCurve(0.22, 1, 0.36, 1, duration: 0.32)
|
||||
/// cycle = 2.4s easeInOut (Lightwell pulses).
|
||||
static let cycle: Animation = .easeInOut(duration: 2.4)
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Builds an adaptive color from explicit light/dark UIColors without
|
||||
/// requiring an asset catalog.
|
||||
init(light: UIColor, dark: UIColor) {
|
||||
self.init(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? dark : light
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared typography roles from the spec. SF Pro default design except the
|
||||
/// rounded welcome heading and wordmark.
|
||||
enum NearlightFont {
|
||||
/// Welcome heading: 32 medium, rounded, largeTitle metrics.
|
||||
static func welcome(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||
scaled(.largeTitle, base: 32, weight: .medium, design: .rounded, sizeCategory: sizeCategory)
|
||||
}
|
||||
/// "Hope" wordmark: 20 semibold, rounded, title3 metrics.
|
||||
static func wordmark(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||
scaled(.title3, base: 20, weight: .semibold, design: .rounded, sizeCategory: sizeCategory)
|
||||
}
|
||||
/// Section/detail heading: 22 semibold, title2 metrics.
|
||||
static func heading(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||
scaled(.title2, base: 22, weight: .semibold, sizeCategory: sizeCategory)
|
||||
}
|
||||
/// Control/row label: 17 semibold, body metrics.
|
||||
static func control(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||
scaled(.body, base: 17, weight: .semibold, sizeCategory: sizeCategory)
|
||||
}
|
||||
/// Status/support: 15 regular, subheadline metrics.
|
||||
static func support(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||
scaled(.subheadline, base: 15, weight: .regular, sizeCategory: sizeCategory)
|
||||
}
|
||||
/// Speaker/timestamp: 13 medium, footnote metrics.
|
||||
static func metadata(_ sizeCategory: DynamicTypeSize) -> Font {
|
||||
scaled(.footnote, base: 13, weight: .medium, sizeCategory: sizeCategory)
|
||||
}
|
||||
|
||||
/// Scales a base size with the given text style via UIFontMetrics and
|
||||
/// returns a SwiftUI font with the requested weight/design.
|
||||
private static func scaled(_ textStyle: UIFont.TextStyle, base: CGFloat, weight: Font.Weight,
|
||||
design: Font.Design = .default, sizeCategory: DynamicTypeSize) -> Font {
|
||||
// UIFontMetrics scales the base size against the CURRENT Dynamic Type
|
||||
// setting at render time (no explicit traits: inherits environment).
|
||||
let scaledSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(for: base)
|
||||
return .system(size: scaledSize, weight: weight, design: design)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
final class SettingsStore: ObservableObject {
|
||||
@Published var serverURL: String {
|
||||
didSet { AppPreferences.store.set(serverURL, forKey: Keys.serverURL) }
|
||||
}
|
||||
@Published var voiceID: String {
|
||||
didSet { AppPreferences.store.set(voiceID, forKey: Keys.voiceID) }
|
||||
}
|
||||
@Published var textMode: Bool {
|
||||
didSet { AppPreferences.store.set(textMode, forKey: Keys.textMode) }
|
||||
}
|
||||
|
||||
private enum Keys {
|
||||
static let serverURL = "serverURL"
|
||||
static let voiceID = "voiceID"
|
||||
static let textMode = "textMode"
|
||||
}
|
||||
|
||||
static let defaultServerURL = "https://192.168.86.2:9178"
|
||||
|
||||
init() {
|
||||
let d = AppPreferences.store
|
||||
// Sanitize legacy values: earlier versions wrote the field to storage on
|
||||
// every keystroke, so an invalid half-typed URL could be persisted and
|
||||
// shipped to the network layer. Fall back to the documented default and
|
||||
// repair the stored value once.
|
||||
let storedURL = d.string(forKey: Keys.serverURL) ?? Self.defaultServerURL
|
||||
switch ServerConfig.parse(storedURL) {
|
||||
case .success:
|
||||
serverURL = storedURL
|
||||
case .failure:
|
||||
// Legacy invalid value: repair to the documented default.
|
||||
serverURL = Self.defaultServerURL
|
||||
d.set(Self.defaultServerURL, forKey: Keys.serverURL)
|
||||
}
|
||||
let storedVoice = d.string(forKey: Keys.voiceID)
|
||||
voiceID = Voices.restoredID(storedVoice)
|
||||
textMode = d.bool(forKey: Keys.textMode)
|
||||
d.set(voiceID, forKey: Keys.voiceID)
|
||||
}
|
||||
|
||||
var trimmedServerURL: String {
|
||||
serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
enum AppPreferences {
|
||||
static let store: UserDefaults = {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
|
||||
let suite = "dev.shane.voice.ui-testing"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
if ProcessInfo.processInfo.arguments.contains("--reset-ui-testing") {
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
// Deterministic offline errors; UI tests must not contact the NAS.
|
||||
defaults.set("https://127.0.0.1:1", forKey: "serverURL")
|
||||
}
|
||||
if ProcessInfo.processInfo.arguments.contains("--live-testing") {
|
||||
defaults.set(SettingsStore.defaultServerURL, forKey: "serverURL")
|
||||
defaults.set(true, forKey: "textMode")
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
#endif
|
||||
return .standard
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
import LiveKit
|
||||
|
||||
@main
|
||||
struct VoiceApp: App {
|
||||
@StateObject private var settings = SettingsStore()
|
||||
|
||||
init() {
|
||||
LiveKitSDK.setLogLevel(.error)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
AuthenticationRoot(settings: settings)
|
||||
.tint(Nearlight.rosewood)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
struct VoiceOption: Codable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
let tag: String?
|
||||
var locale: String? = nil
|
||||
var gender: String? = nil
|
||||
|
||||
var details: String {
|
||||
[locale, gender, tag].compactMap { $0 }.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
tag.map { "\(name) (\($0))" } ?? name
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceCatalogResponse: Decodable {
|
||||
let defaultVoice: String
|
||||
let voices: [VoiceOption]
|
||||
let catalogAvailable: Bool
|
||||
let message: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case voices, message
|
||||
case defaultVoice = "default_voice"
|
||||
case catalogAvailable = "catalog_available"
|
||||
}
|
||||
}
|
||||
|
||||
enum Voices {
|
||||
static let all: [VoiceOption] = [
|
||||
VoiceOption(id: "en-US-Ava:DragonHDOmniLatestNeural", name: "Ava", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Jenny:DragonHDOmniLatestNeural", name: "Jenny", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Guy:DragonHDOmniLatestNeural", name: "Guy", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Andrew:DragonHDOmniLatestNeural", name: "Andrew", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Aria:DragonHDOmniLatestNeural", name: "Aria", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Emma:DragonHDOmniLatestNeural", name: "Emma", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Eric:DragonHDOmniLatestNeural", name: "Eric", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Brian:DragonHDOmniLatestNeural", name: "Brian", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Ashley:DragonHDOmniLatestNeural", name: "Ashley", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Richard:DragonHDOmniLatestNeural", name: "Richard", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Tina:DragonHDOmniLatestNeural", name: "Tina", tag: "Dragon HD Omni"),
|
||||
VoiceOption(id: "en-US-Steffan:DragonHDOmniLatestNeural", name: "Steffan", tag: "Dragon HD Omni"),
|
||||
]
|
||||
|
||||
static var defaultID: String { all[0].id }
|
||||
|
||||
private static let legacyPersonaIDs: [String: String] = Dictionary(uniqueKeysWithValues: all.compactMap { option in
|
||||
guard let prefix = option.id.split(separator: ":").first else { return nil }
|
||||
return ("\(prefix)Neural", option.id)
|
||||
})
|
||||
|
||||
static func restoredID(_ saved: String?) -> String {
|
||||
guard let saved, saved.range(of: "^[A-Za-z0-9:_-]{1,120}$", options: .regularExpression) != nil else { return defaultID }
|
||||
if let migrated = legacyPersonaIDs[saved] { return migrated }
|
||||
return saved.replacingOccurrences(of: ":DragonHDLatestNeural", with: ":DragonHDOmniLatestNeural")
|
||||
}
|
||||
|
||||
static func display(for id: String) -> String? {
|
||||
all.first { $0.id == id }?.displayName
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user