From a33abcae83153cbf1a06f1eaadcf5b1db34a301f Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:05:18 +0300 Subject: [PATCH] Route Apple home loading through headless effects --- .../AppleCore/FluxaAppleCatalogLoader.swift | 13 +++- .../FluxaAppleHeadlessCoordinator.swift | 10 +-- appleApp/iOS/FluxaAppleCatalogBootstrap.swift | 10 +-- appleApp/iOS/FluxaAppleCatalogStartup.swift | 78 +++++++++++++++++-- .../iOS/FluxaAppleHomeEffectHandler.swift | 63 +++++++++++++++ appleApp/iOS/FluxaIosApp.swift | 6 +- 6 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 appleApp/iOS/FluxaAppleHomeEffectHandler.swift diff --git a/appleApp/AppleCore/FluxaAppleCatalogLoader.swift b/appleApp/AppleCore/FluxaAppleCatalogLoader.swift index b8d703d..6a82847 100644 --- a/appleApp/AppleCore/FluxaAppleCatalogLoader.swift +++ b/appleApp/AppleCore/FluxaAppleCatalogLoader.swift @@ -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 diff --git a/appleApp/AppleCore/FluxaAppleHeadlessCoordinator.swift b/appleApp/AppleCore/FluxaAppleHeadlessCoordinator.swift index 138021d..88c6094 100644 --- a/appleApp/AppleCore/FluxaAppleHeadlessCoordinator.swift +++ b/appleApp/AppleCore/FluxaAppleHeadlessCoordinator.swift @@ -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 diff --git a/appleApp/iOS/FluxaAppleCatalogBootstrap.swift b/appleApp/iOS/FluxaAppleCatalogBootstrap.swift index a4a9763..545b2ef 100644 --- a/appleApp/iOS/FluxaAppleCatalogBootstrap.swift +++ b/appleApp/iOS/FluxaAppleCatalogBootstrap.swift @@ -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) } } diff --git a/appleApp/iOS/FluxaAppleCatalogStartup.swift b/appleApp/iOS/FluxaAppleCatalogStartup.swift index 7548b98..ea41ff9 100644 --- a/appleApp/iOS/FluxaAppleCatalogStartup.swift +++ b/appleApp/iOS/FluxaAppleCatalogStartup.swift @@ -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 + } } diff --git a/appleApp/iOS/FluxaAppleHomeEffectHandler.swift b/appleApp/iOS/FluxaAppleHomeEffectHandler.swift new file mode 100644 index 0000000..41c75d3 --- /dev/null +++ b/appleApp/iOS/FluxaAppleHomeEffectHandler.swift @@ -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 + } +} diff --git a/appleApp/iOS/FluxaIosApp.swift b/appleApp/iOS/FluxaIosApp.swift index 6d111d6..f804879 100644 --- a/appleApp/iOS/FluxaIosApp.swift +++ b/appleApp/iOS/FluxaIosApp.swift @@ -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()