scaffolding hermes flow and audit logging

This commit is contained in:
2026-04-09 18:54:10 +02:00
parent e401b6dbab
commit cf5316a2c1
59 changed files with 1830 additions and 593 deletions
+45 -1
View File
@@ -1,15 +1,59 @@
import Foundation
import UIKit
import SwiftUI
@main
struct HermesApp: App {
@StateObject private var repository = HermesRepository(
apiClient: HermesAPIClient(
environment: APIEnvironment(baseURL: URL(string: "http://localhost:3000/")!)
)
)
@StateObject private var analytics = HermesAnalyticsClient()
@StateObject private var playerCoordinator = PlayerCoordinator()
@State private var isBootstrapping = false
var body: some Scene {
WindowGroup {
RootView()
RootView(onStartSession: { localeCode in
if repository.currentSession != nil, repository.currentRound != nil {
return
}
guard !isBootstrapping else {
return
}
isBootstrapping = true
let request = HermesSessionStartRequest(
localeCode: localeCode,
devicePlatform: "ios",
deviceModel: UIDevice.current.model,
osVersion: UIDevice.current.systemVersion,
appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
)
analytics.track("session_start_requested", attributes: ["screen_name": "session", "locale_code": localeCode])
Task { @MainActor in
defer {
isBootstrapping = false
}
do {
_ = try await repository.bootstrap(request)
analytics.track("session_started", attributes: ["screen_name": "session", "locale_code": localeCode])
await analytics.flush(using: repository)
} catch {
analytics.track("session_start_failed", attributes: ["screen_name": "session", "locale_code": localeCode])
}
}
})
.preferredColorScheme(.dark)
.tint(HermesTheme.accent)
.environmentObject(analytics)
.environmentObject(repository)
.environmentObject(playerCoordinator)
}
}
}
@@ -0,0 +1,21 @@
import Foundation
func hermesUserFacingErrorMessage(localization: LocalizationStore, localeCode: String, error: Error?) -> String? {
guard let error else {
return nil
}
if error is CancellationError {
return nil
}
if error is URLError {
return localization.string(for: "errors.network", localeCode: localeCode)
}
if error is HermesAPIError {
return localization.string(for: "errors.generic", localeCode: localeCode)
}
return localization.string(for: "errors.generic", localeCode: localeCode)
}
+145
View File
@@ -0,0 +1,145 @@
import Combine
import Foundation
@MainActor
final class HermesRepository: ObservableObject {
@Published private(set) var currentSession: HermesSessionResponse?
@Published private(set) var currentRound: HermesRound?
@Published private(set) var isLoading = true
@Published private(set) var errorCause: Error?
@Published private(set) var serverClockOffset: TimeInterval?
private let apiClient: HermesAPIClient
init(apiClient: HermesAPIClient) {
self.apiClient = apiClient
}
func bootstrap(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
isLoading = true
errorCause = nil
do {
await syncClock()
let session: HermesSessionResponse
if let existingSession = currentSession {
session = existingSession
} else {
session = try await startSession(request)
}
if currentRound == nil {
currentRound = try await loadRoundFromNetwork()
}
isLoading = false
return session
} catch {
errorCause = error
isLoading = false
throw error
}
}
func refreshRoundFromNetwork() async throws -> HermesRound {
isLoading = true
errorCause = nil
do {
await syncClock()
let round = try await loadRoundFromNetwork()
currentRound = round
isLoading = false
return round
} catch {
errorCause = error
isLoading = false
throw error
}
}
func startSession(_ request: HermesSessionStartRequest) async throws -> HermesSessionResponse {
let session = try await apiClient.startSession(request)
currentSession = session
return session
}
func endSession() async throws -> HermesSessionResponse {
let session = try await apiClient.endSession()
currentSession = session
return session
}
func submitBetIntent(_ request: HermesBetIntentRequest) async throws -> HermesBetIntentResponse {
try await apiClient.submitBetIntent(request)
}
func currentOdds(marketID: UUID) async throws -> HermesOddsVersion {
try await apiClient.currentOdds(marketID: marketID)
}
func settlement(eventID: UUID) async throws -> HermesSettlement {
try await apiClient.settlement(eventID: eventID)
}
func experimentConfig() async throws -> HermesExperimentConfig {
try await apiClient.experimentConfig()
}
func localization(localeCode: String) async throws -> HermesLocalizationBundle {
try await apiClient.localization(localeCode: localeCode)
}
func submitAnalyticsBatch(_ payload: HermesAnalyticsBatchRequest) async throws {
try await apiClient.submitAnalyticsBatch(payload)
}
func serverNow() -> Date {
guard let serverClockOffset else {
return Date()
}
return Date().addingTimeInterval(serverClockOffset)
}
private func syncClock() async {
do {
let health = try await apiClient.health()
serverClockOffset = health.serverTime.timeIntervalSince(Date())
} catch {
return
}
}
private func loadRoundFromNetwork() async throws -> HermesRound {
let event = try await apiClient.nextEvent()
let manifest = try await apiClient.eventManifest(eventID: event.id)
guard let media = manifest.media.first(where: { $0.mediaType == "hls_main" }) ?? manifest.media.first else {
throw HermesAPIError.invalidResponse
}
let market: HermesMarket
if let manifestMarket = manifest.markets.first {
market = manifestMarket
} else {
let markets = try await apiClient.markets(eventID: event.id)
guard let fallbackMarket = markets.first else {
throw HermesAPIError.invalidResponse
}
market = fallbackMarket
}
let oddsVersion = try await apiClient.currentOdds(marketID: market.id)
let settlement = try await apiClient.settlement(eventID: event.id)
return HermesRound(
event: event,
media: media,
market: market,
oddsVersion: oddsVersion,
settlement: settlement
)
}
}
+18 -5
View File
@@ -3,15 +3,19 @@ import SwiftUI
struct RootView: View {
@StateObject private var localization = LocalizationStore()
@EnvironmentObject private var analytics: HermesAnalyticsClient
@EnvironmentObject private var repository: HermesRepository
let onStartSession: (String) -> Void
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
header
OnboardingView()
FeedView()
RoundView()
OnboardingView(onStartSession: { onStartSession(localization.localeCode) })
FeedView(onWatchPreview: {}, onRetry: { onStartSession(localization.localeCode) })
RoundView(onRetry: { onStartSession(localization.localeCode) })
SessionView(onRetry: { onStartSession(localization.localeCode) })
}
.padding(.horizontal, HermesTheme.screenPadding)
.padding(.vertical, 24)
@@ -25,6 +29,15 @@ struct RootView: View {
analytics.track("app_opened", attributes: ["screen_name": "home"])
analytics.track("screen_viewed", attributes: ["screen_name": "home"])
}
.task(id: localization.localeCode) {
onStartSession(localization.localeCode)
}
.task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
await analytics.flush(using: repository)
}
}
}
private var header: some View {
@@ -46,8 +59,8 @@ struct RootView: View {
private var localeToggle: some View {
HStack(spacing: 8) {
localeButton(title: "EN", localeCode: "en")
localeButton(title: "SV", localeCode: "sv")
localeButton(title: localization.localeName(for: "en"), localeCode: "en")
localeButton(title: localization.localeName(for: "sv"), localeCode: "sv")
}
}