fluxa/appleApp/AppleCore/FluxaApplePluginRepositoryManager.swift
KhooLy 270d82d6cd Add FluxaApplePluginRepositoryManager owning a dedicated plugin engine
Mirrors Android's PluginRepositoryManager: owns its own headless
runtime instance, dispatches repo/scraper actions, persists state via
UserDefaults, and exposes scraper settings layout and execution.
2026-07-20 14:43:43 +03:00

320 lines
13 KiB
Swift

import Foundation
struct FluxaApplePluginRepositoryEntry {
let manifestUrl: String
let name: String
let description: String?
let scraperCount: Int
}
struct FluxaApplePluginSettingsOption {
let label: String
let value: String
}
struct FluxaApplePluginSettingsField {
let key: String
let type: String
let label: String
let description: String?
let placeholder: String?
let isPassword: Bool
let defaultValue: String?
let defaultBoolean: Bool
let options: [FluxaApplePluginSettingsOption]
}
struct FluxaApplePluginScraperEntry {
let id: String
let name: String
let repositoryUrl: String
let filename: String
let enabled: Bool
let supportedTypes: [String]
let hasSettings: Bool
let settingsJson: String
}
struct FluxaApplePluginsState {
let repositories: [FluxaApplePluginRepositoryEntry]
let scrapers: [FluxaApplePluginScraperEntry]
let addingRepositoryUrl: String?
let error: String?
static let empty = FluxaApplePluginsState(repositories: [], scrapers: [], addingRepositoryUrl: nil, error: nil)
}
private struct FluxaApplePluginPersistedOverride: Codable {
var enabled: Bool = true
var settingsJson: String = "{}"
}
private struct FluxaApplePluginPersistedState: Codable {
var repositoryUrls: [String] = []
var scraperOverrides: [String: FluxaApplePluginPersistedOverride] = [:]
}
final class FluxaApplePluginRepositoryManager {
private let runtime: FluxaAppleHeadlessRuntime
private let coordinator: FluxaAppleHeadlessCoordinator
private let httpClient = FluxaApplePluginHttpClient()
private let session: URLSession
private let defaults: UserDefaults
private let codeCacheLock = NSLock()
private var codeCache: [String: String] = [:]
init(session: URLSession = .shared, defaults: UserDefaults = .standard) {
let runtime = requireFluxaAppleHeadlessRuntime()
self.runtime = runtime
self.coordinator = FluxaAppleHeadlessCoordinator(
runtime: runtime,
executor: FluxaApplePlatformEffectExecutor(handler: FluxaApplePluginsEffectHandler(session: session))
)
self.session = session
self.defaults = defaults
}
func restore() async -> FluxaApplePluginsState {
let persisted = loadPersistedState()
for manifestUrl in persisted.repositoryUrls {
_ = try? await dispatch(["type": "pluginRepositoryAddRequested", "manifestUrl": manifestUrl])
}
for (scraperId, override) in persisted.scraperOverrides {
if !override.enabled {
_ = try? await dispatch(["type": "pluginScraperToggled", "scraperId": scraperId, "enabled": false])
}
if let settings = jsonObject(from: override.settingsJson), !settings.isEmpty {
_ = try? await dispatch(["type": "pluginScraperSettingsUpdated", "scraperId": scraperId, "settings": settings])
}
}
return currentState()
}
func addRepository(manifestUrl: String) async -> FluxaApplePluginsState {
let state = (try? await dispatch(["type": "pluginRepositoryAddRequested", "manifestUrl": manifestUrl])).map(pluginsState) ?? currentState()
persist(state)
return state
}
func removeRepository(manifestUrl: String) async -> FluxaApplePluginsState {
let state = (try? await dispatch(["type": "pluginRepositoryRemoveRequested", "manifestUrl": manifestUrl])).map(pluginsState) ?? currentState()
persist(state)
return state
}
func refreshRepository(manifestUrl: String) async -> FluxaApplePluginsState {
await addRepository(manifestUrl: manifestUrl)
}
func toggleScraper(scraperId: String, enabled: Bool) async -> FluxaApplePluginsState {
let state = (try? await dispatch(["type": "pluginScraperToggled", "scraperId": scraperId, "enabled": enabled])).map(pluginsState) ?? currentState()
persist(state)
return state
}
func updateScraperSettings(scraperId: String, settingsJson: String) async -> FluxaApplePluginsState {
let settings = jsonObject(from: settingsJson) ?? [:]
let state = (try? await dispatch(["type": "pluginScraperSettingsUpdated", "scraperId": scraperId, "settings": settings])).map(pluginsState) ?? currentState()
persist(state)
return state
}
func getSettingsLayout(scraper: FluxaApplePluginScraperEntry) async -> [FluxaApplePluginSettingsField] {
guard let code = await fetchScraperCode(scraper) else { return [] }
let layoutJson = getPluginScraperSettingsLayout(code: code, scraperId: scraper.id)
return parseSettingsLayout(layoutJson)
}
func executeScraper(
scraper: FluxaApplePluginScraperEntry,
tmdbId: String,
mediaType: String,
season: Int32?,
episode: Int32?
) async -> String {
guard let code = await fetchScraperCode(scraper) else { return "[]" }
let rawJson = executePluginScraper(
client: httpClient,
code: code,
scraperId: scraper.id,
scraperSettingsJson: scraper.settingsJson,
tmdbId: tmdbId,
mediaType: mediaType,
season: season,
episode: episode
)
return coreInvoke(method: "pluginStreamResultsToStreams", argsJson: rawJson)
}
func currentState() -> FluxaApplePluginsState {
pluginsState(fromStateJson: runtime.snapshot())
}
private func dispatch(_ fields: [String: Any]) async throws -> FluxaAppleHeadlessResult {
let data = try JSONSerialization.data(withJSONObject: fields)
let json = String(decoding: data, as: UTF8.self)
return try await coordinator.dispatch(actionJson: json)
}
private func pluginsState(_ result: FluxaAppleHeadlessResult) -> FluxaApplePluginsState {
pluginsState(from: result.state)
}
private func pluginsState(fromStateJson json: String) -> FluxaApplePluginsState {
guard let result = try? JSONDecoder().decode(FluxaAppleHeadlessResult.self, from: Data(json.utf8)) else {
return .empty
}
return pluginsState(from: result.state)
}
private func pluginsState(from state: [String: FluxaAppleJsonValue]) -> FluxaApplePluginsState {
guard case .object(let plugins)? = state["plugins"] else { return .empty }
let repositories = array(plugins["repositories"]).compactMap { repositoryEntry($0) }
let scrapers = array(plugins["scrapers"]).compactMap { scraperEntry($0) }
let addingRepositoryUrl = text(plugins["addingRepositoryUrl"])
let error: String?
if let errorValue = plugins["error"], case .object(let errorObject) = errorValue {
error = text(errorObject["code"])
} else {
error = nil
}
return FluxaApplePluginsState(repositories: repositories, scrapers: scrapers, addingRepositoryUrl: addingRepositoryUrl, error: error)
}
private func repositoryEntry(_ value: FluxaAppleJsonValue) -> FluxaApplePluginRepositoryEntry? {
guard case .object(let object) = value, let manifestUrl = text(object["manifestUrl"]) else { return nil }
return FluxaApplePluginRepositoryEntry(
manifestUrl: manifestUrl,
name: text(object["name"]) ?? "",
description: text(object["description"]),
scraperCount: number(object["scraperCount"]).map(Int.init) ?? 0
)
}
private func scraperEntry(_ value: FluxaAppleJsonValue) -> FluxaApplePluginScraperEntry? {
guard case .object(let object) = value, let id = text(object["id"]) else { return nil }
let settingsJson: String
if let settingsValue = object["settings"], case .object = settingsValue {
settingsJson = jsonString(settingsValue)
} else {
settingsJson = "{}"
}
return FluxaApplePluginScraperEntry(
id: id,
name: text(object["name"]) ?? "",
repositoryUrl: text(object["repositoryUrl"]) ?? "",
filename: text(object["filename"]) ?? "",
enabled: boolean(object["enabled"]) ?? true,
supportedTypes: array(object["supportedTypes"]).compactMap { text($0) },
hasSettings: boolean(object["hasSettings"]) ?? false,
settingsJson: settingsJson
)
}
private func fetchScraperCode(_ scraper: FluxaApplePluginScraperEntry) async -> String? {
codeCacheLock.lock()
let cached = codeCache[scraper.id]
codeCacheLock.unlock()
if let cached { return cached }
let base = scraper.repositoryUrl
.split(separator: "/", omittingEmptySubsequences: false)
.dropLast()
.joined(separator: "/")
guard let url = URL(string: "\(base)/\(scraper.filename)") else { return nil }
do {
let (data, response) = try await session.data(from: url)
guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else {
return nil
}
let code = String(decoding: data, as: UTF8.self)
guard !code.isEmpty else { return nil }
codeCacheLock.lock()
codeCache[scraper.id] = code
codeCacheLock.unlock()
return code
} catch {
return nil
}
}
private func parseSettingsLayout(_ layoutJson: String) -> [FluxaApplePluginSettingsField] {
guard let data = layoutJson.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return []
}
return array.compactMap { field in
guard let type = field["type"] as? String else { return nil }
let options = (field["options"] as? [[String: Any]] ?? []).map {
FluxaApplePluginSettingsOption(label: $0["label"] as? String ?? "", value: $0["value"] as? String ?? "")
}
let defaultValueRaw = field["defaultValue"]
let defaultIsBoolean = defaultValueRaw is Bool
return FluxaApplePluginSettingsField(
key: field["key"] as? String ?? "",
type: type,
label: field["label"] as? String ?? "",
description: field["description"] as? String,
placeholder: field["placeholder"] as? String,
isPassword: field["isPassword"] as? Bool ?? false,
defaultValue: defaultIsBoolean ? nil : defaultValueRaw as? String,
defaultBoolean: defaultIsBoolean ? (defaultValueRaw as? Bool ?? false) : false,
options: options
)
}
}
private func persist(_ state: FluxaApplePluginsState) {
var persisted = FluxaApplePluginPersistedState()
persisted.repositoryUrls = state.repositories.map(\.manifestUrl)
for scraper in state.scrapers {
persisted.scraperOverrides[scraper.id] = FluxaApplePluginPersistedOverride(
enabled: scraper.enabled,
settingsJson: scraper.settingsJson
)
}
guard let data = try? JSONEncoder().encode(persisted) else { return }
defaults.set(String(decoding: data, as: UTF8.self), forKey: Self.persistedStateKey)
}
private func loadPersistedState() -> FluxaApplePluginPersistedState {
guard let json = defaults.string(forKey: Self.persistedStateKey),
let data = json.data(using: .utf8),
let state = try? JSONDecoder().decode(FluxaApplePluginPersistedState.self, from: data) else {
return FluxaApplePluginPersistedState()
}
return state
}
private func jsonObject(from json: String) -> [String: Any]? {
guard let data = json.data(using: .utf8) else { return nil }
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
}
private func jsonString(_ value: FluxaAppleJsonValue) -> String {
guard let data = try? JSONEncoder().encode(value) else { return "{}" }
return String(decoding: data, as: UTF8.self)
}
private func array(_ value: FluxaAppleJsonValue?) -> [FluxaAppleJsonValue] {
guard case .array(let values)? = value else { return [] }
return values
}
private func text(_ value: FluxaAppleJsonValue?) -> String? {
guard case .string(let text)? = value else { return nil }
return text
}
private func boolean(_ value: FluxaAppleJsonValue?) -> Bool? {
guard case .boolean(let flag)? = value else { return nil }
return flag
}
private func number(_ value: FluxaAppleJsonValue?) -> Double? {
guard case .number(let number)? = value else { return nil }
return number
}
private static let persistedStateKey = "fluxa.apple.pluginRepositoryState"
}