Route Apple home loading through headless effects

This commit is contained in:
KhooLy 2026-07-13 16:05:18 +03:00
parent 221dd8d4ec
commit a33abcae83
6 changed files with 159 additions and 21 deletions

View file

@ -19,6 +19,11 @@ final class FluxaAppleCatalogLoader {
}
func loadSnapshot(requests: [FluxaAppleCatalogRequest]) async throws -> String {
let rows = try await loadRows(requests: requests)
return String(decoding: try encoder.encode(FluxaAppleCatalogSnapshot(rows: rows)), as: UTF8.self)
}
func loadRows(requests: [FluxaAppleCatalogRequest]) async throws -> [FluxaAppleCatalogRow] {
var rows = [FluxaAppleCatalogRow]()
var lastError: Error?
for request in requests {
@ -55,7 +60,7 @@ final class FluxaAppleCatalogLoader {
if rows.isEmpty, let lastError {
throw lastError
}
return String(decoding: try encoder.encode(FluxaAppleCatalogSnapshot(rows: rows)), as: UTF8.self)
return rows
}
}
@ -72,19 +77,19 @@ private struct FluxaAppleStremioMeta: Decodable {
let releaseInfo: String?
}
private struct FluxaAppleCatalogSnapshot: Encodable {
struct FluxaAppleCatalogSnapshot: Encodable {
let rows: [FluxaAppleCatalogRow]
let isLoading = false
}
private struct FluxaAppleCatalogRow: Encodable {
struct FluxaAppleCatalogRow: Encodable {
let id: String
let title: String
let canLoadMore: Bool
let items: [FluxaAppleCatalogItem]
}
private struct FluxaAppleCatalogItem: Encodable {
struct FluxaAppleCatalogItem: Encodable {
let id: String
let type: String
let title: String

View file

@ -82,7 +82,7 @@ protocol FluxaAppleHeadlessEffectExecutor: AnyObject {
final class FluxaAppleHeadlessCoordinator {
private let runtime: FluxaAppleHeadlessRuntime
private weak var executor: (any FluxaAppleHeadlessEffectExecutor)?
private let executor: any FluxaAppleHeadlessEffectExecutor
private let maxEffectsPerDispatch: Int
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
@ -107,13 +107,13 @@ final class FluxaAppleHeadlessCoordinator {
private func drain(_ initialJson: String) async throws -> FluxaAppleHeadlessResult {
var current = try decode(initialJson)
var pending = current.effects
var remaining = maxEffectsPerDispatch
while let effect = current.effects.first, remaining > 0 {
guard let executor else {
return current
}
while !pending.isEmpty, remaining > 0 {
let effect = pending.removeFirst()
let completion = await executor.execute(effect: effect)
current = try decode(runtime.completeEffect(resultJson: try encode(completion)))
pending.append(contentsOf: current.effects)
remaining -= 1
}
return current

View file

@ -1,4 +1,3 @@
import FluxaShared
import Foundation
@MainActor
@ -14,13 +13,12 @@ final class FluxaAppleCatalogBootstrap {
self.resolver = resolver
}
func refresh(requests: [FluxaAppleCatalogRequest]) async throws {
let snapshot = try await loader.loadSnapshot(requests: requests)
FluxaApple.shared.updateCatalogHomeJson(homeJson: snapshot)
func loadRows(requests: [FluxaAppleCatalogRequest]) async throws -> [FluxaAppleCatalogRow] {
try await loader.loadRows(requests: requests)
}
func refresh(localAddonUrls: [String]) async throws {
func loadRows(localAddonUrls: [String]) async throws -> [FluxaAppleCatalogRow] {
let requests = try await resolver.resolveRequests(localAddonUrls: localAddonUrls)
try await refresh(requests: requests)
return try await loadRows(requests: requests)
}
}

View file

@ -26,22 +26,90 @@ final class FluxaAppleAddonConfigurationStore {
@MainActor
final class FluxaAppleCatalogStartup {
private let configurationStore: FluxaAppleAddonConfigurationStore
private let bootstrap: FluxaAppleCatalogBootstrap
private let coordinator: FluxaAppleHeadlessCoordinator
private let encoder = JSONEncoder()
init(
runtime: FluxaAppleHeadlessRuntime,
configurationStore: FluxaAppleAddonConfigurationStore = FluxaAppleAddonConfigurationStore(),
bootstrap: FluxaAppleCatalogBootstrap = FluxaAppleCatalogBootstrap()
) {
self.configurationStore = configurationStore
self.bootstrap = bootstrap
let handler = FluxaAppleHomeEffectHandler(
configurationStore: configurationStore,
catalogBootstrap: bootstrap
)
coordinator = FluxaAppleHeadlessCoordinator(
runtime: runtime,
executor: FluxaApplePlatformEffectExecutor(handler: handler)
)
}
func refresh() async {
do {
try await bootstrap.refresh(localAddonUrls: configurationStore.localAddonUrls())
let result = try await coordinator.dispatch(
actionJson: "{\"type\":\"homeLoadRequested\",\"profile\":{\"id\":\"apple-default\"},\"language\":\"en\",\"force\":true}"
)
updateSharedHome(result: result)
} catch {
FluxaApple.shared.updateCatalogHomeJson(homeJson: "{\"rows\":[],\"isLoading\":false}")
}
}
private func updateSharedHome(result: FluxaAppleHeadlessResult) {
guard case .object(let home)? = result.state["home"],
case .array(let categories)? = home["categories"] else {
FluxaApple.shared.updateCatalogHomeJson(homeJson: "{\"rows\":[],\"isLoading\":false}")
return
}
let rows = categories.compactMap(sharedRow)
let snapshot = FluxaAppleJsonValue.object([
"rows": .array(rows),
"isLoading": .boolean(false)
])
guard let data = try? encoder.encode(snapshot) else {
return
}
FluxaApple.shared.updateCatalogHomeJson(homeJson: String(decoding: data, as: UTF8.self))
}
private func sharedRow(_ category: FluxaAppleJsonValue) -> FluxaAppleJsonValue? {
guard case .object(let value) = category,
let id = string(value["id"]),
let title = string(value["name"]),
case .array(let items)? = value["items"] else {
return nil
}
return .object([
"id": .string(id),
"title": .string(title),
"canLoadMore": .boolean(false),
"items": .array(items.compactMap(sharedItem))
])
}
private func sharedItem(_ item: FluxaAppleJsonValue) -> FluxaAppleJsonValue? {
guard case .object(let value) = item,
let id = string(value["id"]),
let type = string(value["type"]),
let title = string(value["name"]) else {
return nil
}
return .object([
"id": .string(id),
"type": .string(type),
"title": .string(title),
"subtitle": value["releaseInfo"] ?? .string(""),
"artworkUrl": value["poster"] ?? .null,
"logoUrl": value["logo"] ?? .null,
"addonTransportUrl": value["addonTransportUrl"] ?? .null,
"catalogType": value["catalogType"] ?? .null
])
}
private func string(_ value: FluxaAppleJsonValue?) -> String? {
guard case .string(let text)? = value else {
return nil
}
return text
}
}

View file

@ -0,0 +1,63 @@
import Foundation
final class FluxaAppleHomeEffectHandler: FluxaApplePlatformEffectHandler {
private let configurationStore: FluxaAppleAddonConfigurationStore
private let catalogBootstrap: FluxaAppleCatalogBootstrap
init(
configurationStore: FluxaAppleAddonConfigurationStore,
catalogBootstrap: FluxaAppleCatalogBootstrap
) {
self.configurationStore = configurationStore
self.catalogBootstrap = catalogBootstrap
}
func execute(effect: FluxaAppleHeadlessEffect) async throws -> FluxaAppleJsonValue {
switch effect.type {
case "readHomeBootstrap":
let rows = try await catalogBootstrap.loadRows(
localAddonUrls: configurationStore.localAddonUrls()
)
return .object([
"categories": .array(rows.map(homeCategory)),
"continueWatching": .array([]),
"watchlist": .array([]),
"userAddons": .array(configurationStore.localAddonUrls().map { .string($0) }),
"metadataFeeds": .array([]),
"billboard": rows.first?.items.first.map(homeMeta) ?? .null
])
case "refreshContinueWatching":
return .object(["continueWatching": .array([])])
default:
throw NSError(domain: "FluxaAppleUnsupportedEffect", code: 1)
}
}
private func homeCategory(_ row: FluxaAppleCatalogRow) -> FluxaAppleJsonValue {
.object([
"id": .string(row.id),
"name": .string(row.title),
"semanticName": .string(row.title),
"type": .string(row.items.first?.type ?? ""),
"catalogId": .string(row.id),
"items": .array(row.items.map(homeMeta))
])
}
private func homeMeta(_ item: FluxaAppleCatalogItem) -> FluxaAppleJsonValue {
.object([
"id": .string(item.id),
"type": .string(item.type),
"name": .string(item.title),
"poster": optionalString(item.artworkUrl),
"logo": optionalString(item.logoUrl),
"releaseInfo": .string(item.subtitle),
"addonTransportUrl": optionalString(item.addonTransportUrl),
"catalogType": optionalString(item.catalogType)
])
}
private func optionalString(_ value: String?) -> FluxaAppleJsonValue {
value.map(FluxaAppleJsonValue.string) ?? .null
}
}

View file

@ -4,9 +4,13 @@ import SwiftUI
struct FluxaIosApp: App {
private let coreVersion = FluxaRustCoreRuntime.version()
private let headlessRuntime = requireFluxaAppleHeadlessRuntime()
private let catalogStartup = FluxaAppleCatalogStartup()
private let catalogStartup: FluxaAppleCatalogStartup
@State private var hasStartedCatalog = false
init() {
catalogStartup = FluxaAppleCatalogStartup(runtime: headlessRuntime)
}
var body: some Scene {
WindowGroup {
FluxaRootView()