diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index df2a1f7c93..7c0557da89 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -9,7 +9,7 @@ enum PluginError: LocalizedError { case cannotUninstallBuiltIn case notFound case registryUnreachable - case noCompatibleBinary + case noCompatibleRelease case appTooOldForPlugin(oldestPublishedKit: Int, appKit: Int) case installFailed(String) case pluginConflict(existingName: String) @@ -40,8 +40,8 @@ enum PluginError: LocalizedError { return String(localized: "Plugin not found") case .registryUnreachable: return String(localized: "Couldn't reach the plugin registry. Check your connection and try again.") - case .noCompatibleBinary: - return String(localized: "Plugin does not contain a compatible binary for this architecture") + case .noCompatibleRelease: + return String(localized: "No build of this plugin works with this version of TablePro.") case .appTooOldForPlugin: return String(localized: "This plugin needs a newer version of TablePro. Update TablePro, then install it again.") case .installFailed(let reason): @@ -94,7 +94,7 @@ enum PluginError: LocalizedError { var isPermanentReconciliationFailure: Bool { switch self { - case .noCompatibleBinary, .appTooOldForPlugin, .incompatibleVersion: + case .noCompatibleRelease, .appTooOldForPlugin, .incompatibleVersion: return true default: return false diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index b01632bf0e..075facbe79 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -14,14 +14,14 @@ actor PluginInstaller { func install( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndCommit( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -30,7 +30,7 @@ actor PluginInstaller { func update( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, hasLiveConnections: Bool, progress: @escaping @Sendable (Double) async -> Void @@ -39,7 +39,7 @@ actor PluginInstaller { let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndStage( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -51,7 +51,7 @@ actor PluginInstaller { let finalURL = try await runCoalesced(pluginId: registryPlugin.id) { try await self.performDownloadAndCommit( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -113,13 +113,13 @@ actor PluginInstaller { private func performDownloadAndCommit( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { let extracted = try await downloadExtractVerify( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -136,13 +136,13 @@ actor PluginInstaller { private func performDownloadAndStage( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { let extracted = try await downloadExtractVerify( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: progress ) @@ -158,7 +158,7 @@ actor PluginInstaller { private func downloadExtractVerify( _ registryPlugin: RegistryPlugin, - binary: RegistryBinary, + release: RegistryRelease, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> ExtractedBundle { @@ -169,10 +169,8 @@ actor PluginInstaller { let session = await MainActor.run { RegistryClient.shared.session } - // The registry manifest is fetched over the network, so the URL it names is untrusted - // input. The code-signature check is the real gate, but nothing should be fetched over - // cleartext on the way to it. - guard let downloadURL = URL(string: binary.downloadURL), + // The manifest is network input: the signature check is the real gate, but nothing travels over cleartext. + guard let downloadURL = URL(string: release.url), downloadURL.scheme?.lowercased() == "https" else { throw PluginError.downloadFailed("Invalid download URL") } @@ -183,7 +181,7 @@ actor PluginInstaller { throw PluginError.downloadFailed("HTTP \(code)") } - guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == binary.sha256.lowercased() else { + guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == release.sha256.lowercased() else { throw PluginError.checksumMismatch } diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 1b92832dcd..a54c2caba0 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -34,7 +34,7 @@ extension PluginManager { return } - await RegistryClient.shared.fetchManifest(forceRefresh: true) + await RegistryClient.shared.fetch(policy: .mustBeCurrent) refreshRegistryUpdateSet() guard let manifest = RegistryClient.shared.manifest else { reconciliationManifestAttempts += 1 @@ -94,11 +94,7 @@ extension PluginManager { reconciliationAttempts[lookupId] = attempts + 1 do { - let outcome = try await updateFromRegistry( - registryPlugin, - refreshManifest: false, - progress: { _ in } - ) + let outcome = try await updateFromRegistry(registryPlugin, progress: { _ in }) switch outcome { case .installed: refreshRegistryUpdateSet() @@ -111,8 +107,8 @@ extension PluginManager { return .resolved } catch let error as PluginError where error.isPermanentReconciliationFailure { let action = Self.rejectedAction(registryPlugin: registryPlugin, manifestLoaded: true, kits: Self.compatibleKits) - updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin)) - if case .noCompatibleBinary = error, case .awaitingCompatibleBuild = action { + updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: action)) + if case .noCompatibleRelease = error, case .awaitingCompatibleBuild = action { Self.logger.warning("Reconciliation: no compatible build published yet for '\(rejected.name)', will retry") return .transient(id: lookupId) } @@ -161,11 +157,8 @@ extension PluginManager { ) } - private func incompatibleBuildReason(for registryPlugin: RegistryPlugin) -> String { - let availableKits = registryPlugin.binaries - .filter { $0.architecture == .current } - .compactMap(\.pluginKitVersion) - if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) { + private func incompatibleBuildReason(for action: RejectedPluginAction) -> String { + if case .requiresAppUpdate = action { return String(localized: "A newer version of TablePro is required for this plugin. Update TablePro to keep using it.") } return String(localized: "No compatible build is available yet. This plugin will update automatically once one is published.") @@ -202,13 +195,14 @@ extension PluginManager { rejectedPlugins.removeAll { $0.url == url } } - func registryUpdate(for pluginId: String) -> RegistryPlugin? { + func registryUpdate(for pluginId: String) -> (plugin: RegistryPlugin, release: RegistryRelease)? { guard let manifest = RegistryClient.shared.manifest else { return nil } guard let installed = plugins.first(where: { $0.id == pluginId }) else { return nil } guard installed.source == .userInstalled else { return nil } - guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }) else { return nil } - return registryPlugin.version.compare(installed.version, options: .numeric) == .orderedDescending - ? registryPlugin : nil + guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }), + let release = try? Self.newestRelease(of: registryPlugin), + release.build > installed.build else { return nil } + return (registryPlugin, release) } func refreshRegistryUpdateSet() { @@ -242,16 +236,14 @@ extension PluginManager { ) -> RejectedPluginAction { guard manifestLoaded else { return .awaitingCompatibleBuild } guard let registryPlugin else { return .notInRegistry } - let availableKits = registryPlugin.binaries - .filter { $0.architecture == .current } - .compactMap(\.pluginKitVersion) - if availableKits.contains(where: { kits.contains($0) }) { + do { + _ = try newestRelease(of: registryPlugin, kits: kits) return .updateAvailable(registryPlugin) - } - if availableKits.contains(where: { $0 > kits.upperBound }) { + } catch PluginError.appTooOldForPlugin { return .requiresAppUpdate + } catch { + return .awaitingCompatibleBuild } - return .awaitingCompatibleBuild } func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool { @@ -270,7 +262,7 @@ extension PluginManager { } guard driverPlugin(for: type) == nil else { return } if PluginMetadataRegistry.shared.snapshot(for: type) == nil { - await registryClient.ensureManifest(.ifStale) + await registryClient.fetch(policy: .ifStale) } let unavailability = driverUnavailability(for: type, registryManifest: registryClient.manifest) guard unavailability == .notInstalled else { @@ -304,7 +296,7 @@ extension PluginManager { guard !targets.isEmpty else { return } reconciliationActive = true defer { reconciliationActive = false } - await RegistryClient.shared.fetchManifest(forceRefresh: true) + await RegistryClient.shared.fetch(policy: .mustBeCurrent) guard let manifest = RegistryClient.shared.manifest else { return } for target in targets { if let lookupId = resolveRegistryId(for: target, manifest: manifest) { diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index 8540eb1842..48acda58ac 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -4,7 +4,6 @@ import os extension PluginManager { func installFromRegistry( _ registryPlugin: RegistryPlugin, - registryClient: RegistryClient = .shared, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginEntry { if plugins.contains(where: { $0.id == registryPlugin.id }) { @@ -18,12 +17,11 @@ extension PluginManager { installsInFlight.insert(registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } - let registryPlugin = await registryClient.refreshedPlugin(matching: registryPlugin) - let binary = try validateRegistryCompatibility(registryPlugin) + let release = try Self.newestRelease(of: registryPlugin) let finalURL = try await PluginInstaller.shared.install( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, progress: { fraction in await progress(fraction) } ) @@ -36,7 +34,6 @@ extension PluginManager { func updateFromRegistry( _ registryPlugin: RegistryPlugin, - refreshManifest: Bool = true, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginUpdateOutcome { if let existing = plugins.first(where: { $0.id == registryPlugin.id }), @@ -52,14 +49,11 @@ extension PluginManager { installsInFlight.insert(registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } - let registryPlugin = refreshManifest - ? await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) - : registryPlugin - let binary = try validateRegistryCompatibility(registryPlugin) + let release = try Self.newestRelease(of: registryPlugin) let result = try await PluginInstaller.shared.update( registryPlugin, - binary: binary, + release: release, into: userPluginsDir, hasLiveConnections: pluginHasLiveConnections(registryPlugin), progress: { fraction in await progress(fraction) } @@ -83,7 +77,7 @@ extension PluginManager { ) PluginInstallTracker.shared.markStaged( pluginId: registryPlugin.id, - newVersion: registryPlugin.version + newVersion: release.version ) return .staged(pluginId: registryPlugin.id) } @@ -136,9 +130,15 @@ extension PluginManager { PluginInstallTracker.shared.clearInstall(pluginId: pluginId) } - @discardableResult - func validateRegistryCompatibility(_ registryPlugin: RegistryPlugin) throws -> RegistryBinary { - try registryPlugin.resolvedBinary(for: .current, kits: Self.compatibleKits) + nonisolated static func newestRelease( + of registryPlugin: RegistryPlugin, + kits: ClosedRange = compatibleKits + ) throws -> RegistryRelease { + try registryPlugin.newestRelease( + channel: registryChannel, + kits: kits, + systemVersion: ProcessInfo.processInfo.operatingSystemVersion + ) } func pluginHasLiveConnections(_ registryPlugin: RegistryPlugin) -> Bool { diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index fedfc28013..db9da5632e 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -758,16 +758,16 @@ extension PluginManager { progress: @escaping @MainActor @Sendable (Double) -> Void, lookup: (RegistryManifest?) -> RegistryPlugin? ) async throws -> PluginEntry { - await registryClient.ensureManifest(.ifStale) + await registryClient.fetch(policy: .ifStale) var registryPlugin = lookup(registryClient.manifest) if registryPlugin == nil { - await registryClient.ensureManifest(.mustBeCurrent) + await registryClient.fetch(policy: .mustBeCurrent) registryPlugin = lookup(registryClient.manifest) } guard let registryPlugin else { - guard registryClient.fetchState == .loaded else { + guard registryClient.isManifestCurrent else { throw PluginError.registryUnreachable } throw PluginError.notFound @@ -779,7 +779,6 @@ extension PluginManager { do { let entry = try await installFromRegistry( registryPlugin, - registryClient: registryClient, progress: { fraction in tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) progress(fraction) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 4f8586d891..cf32a5bba8 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -459,6 +459,9 @@ final class PluginManager: ObservableObject { nonisolated internal static let compatibleKits = minimumCompatiblePluginKitVersion...currentPluginKitVersion + // A plugin matches the build it ships with, so a beta app takes beta plugins whatever the update setting. + nonisolated internal static let registryChannel: UpdateChannel = Bundle.main.isPrerelease ? .beta : .stable + nonisolated internal static func isCompatibleKit(_ kit: Int) -> Bool { compatibleKits.contains(kit) } diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index acb1489f17..16b68416bf 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -1,238 +1,111 @@ -// -// RegistryClient.swift -// TablePro -// - import Combine import Foundation import os @MainActor -final class RegistryClient: ObservableObject { - static let shared = RegistryClient() +internal final class RegistryClient: ObservableObject { + internal static let shared = RegistryClient() - @Published private(set) var manifest: RegistryManifest? - @Published private(set) var fetchState: RegistryFetchState = .idle - @Published private(set) var lastFetchDate: Date? + nonisolated internal static let defaultManifestURL: URL = { + guard let url = URL(string: "https://updates.tablepro.app/plugins/v3.json") else { + preconditionFailure("The plugin manifest URL does not parse") + } + return url + }() - let session: URLSession - static let supportedSchemaVersion = 2 nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient") - private static let manifestFreshnessWindow: TimeInterval = 300 - - private static let defaultRegistryURL = URL(string: - "https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")! - static let customRegistryURLKey = "com.TablePro.customRegistryURL" - private static let lastFetchKey = "com.TablePro.registryLastFetch" - private static let legacyManifestCacheKey = "registryManifestCache" - private static let legacyETagKeys = ["registryETag", "com.TablePro.registryETag"] - private static let legacyLastFetchKey = "registryLastFetch" - private static let legacyLastRegistryURLKey = "com.TablePro.lastRegistryURL" + @Published internal private(set) var manifest: RegistryManifest? + internal private(set) var isManifestCurrent = false - private let defaults: UserDefaults - private let manifestCacheURL: URL + internal let session: URLSession + internal let manifestURL: URL - private var inFlightFetch: Task? - private var lastFetchedURL: URL? + private var inFlight: InFlightFetch? - var isUsingCustomRegistry: Bool { - registryURL != Self.defaultRegistryURL - } - - private var registryURL: URL { - if let raw = defaults.string(forKey: Self.customRegistryURLKey), - let custom = URL(string: raw) { - return custom - } - return Self.defaultRegistryURL + private struct InFlightFetch { + let policy: RegistryFetchPolicy + let task: Task } - nonisolated private static let manifestCacheFileName = "registry-manifest.json" - - init( - userDefaults: UserDefaults = AppStorageEnvironment.shared.defaults, - session: URLSession = RegistryClient.makeDefaultSession(), - manifestCacheURL: URL = RegistryClient.defaultManifestCacheURL() + internal init( + manifestURL: URL = RegistryClient.defaultManifestURL, + session: URLSession = RegistryClient.makeDefaultSession() ) { - self.defaults = userDefaults + self.manifestURL = manifestURL self.session = session - self.manifestCacheURL = manifestCacheURL - Self.migrateLegacyKeys(in: userDefaults) - migrateManifestCacheLocationIfNeeded() - loadCachedManifest() + manifest = Self.cachedManifest(for: manifestURL, in: session.configuration.urlCache) } - nonisolated static func makeDefaultSession() -> URLSession { + nonisolated internal static func makeDefaultSession() -> URLSession { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 config.waitsForConnectivity = true + // URLCache stores nothing larger than a twentieth of its capacity. config.urlCache = URLCache( - memoryCapacity: 1_000_000, - diskCapacity: 5_000_000, + memoryCapacity: 2_000_000, + diskCapacity: 20_000_000, directory: AppStorageEnvironment.shared.applicationSupportRoot .appendingPathComponent("TablePro/Registry/URLCache", isDirectory: true) ) return URLSession(configuration: config) } - nonisolated static func defaultManifestCacheURL() -> URL { - AppStorageEnvironment.shared.applicationSupportRoot - .appendingPathComponent("TablePro/Registry", isDirectory: true) - .appendingPathComponent(manifestCacheFileName) - } - - private static func migrateLegacyKeys(in defaults: UserDefaults) { - let obsoleteKeys = legacyETagKeys + [legacyLastFetchKey, legacyManifestCacheKey, legacyLastRegistryURLKey] - for key in obsoleteKeys where defaults.object(forKey: key) != nil { - defaults.removeObject(forKey: key) - } - } - - private func migrateManifestCacheLocationIfNeeded() { - guard manifestCacheURL == Self.defaultManifestCacheURL() else { return } - let fm = FileManager.default - guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return } - let bundleId = Bundle.main.bundleIdentifier ?? "com.TablePro" - let legacyURL = cachesDir.appendingPathComponent(bundleId, isDirectory: true) - .appendingPathComponent(Self.manifestCacheFileName) - guard fm.fileExists(atPath: legacyURL.path) else { return } - if !fm.fileExists(atPath: manifestCacheURL.path) { - do { - try fm.createDirectory( - at: manifestCacheURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try fm.copyItem(at: legacyURL, to: manifestCacheURL) - } catch { - Self.logger.warning("Failed to migrate registry manifest cache: \(error.localizedDescription)") - return - } - } - try? fm.removeItem(at: legacyURL) - } - - private func loadCachedManifest() { - guard let data = try? Data(contentsOf: manifestCacheURL), - let cached = try? JSONDecoder().decode(RegistryManifest.self, from: data) - else { return } - manifest = cached - lastFetchDate = defaults.object(forKey: Self.lastFetchKey) as? Date - } - - private func writeCachedManifest(_ data: Data) { - let dir = manifestCacheURL.deletingLastPathComponent() + nonisolated private static func cachedManifest(for url: URL, in cache: URLCache?) -> RegistryManifest? { + guard let cached = cache?.cachedResponse(for: URLRequest(url: url)) else { return nil } do { - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try data.write(to: manifestCacheURL, options: .atomic) + return try JSONDecoder().decode(RegistryManifest.self, from: cached.data) } catch { - Self.logger.warning("Failed to write registry cache: \(error.localizedDescription)") + logger.warning("Ignoring the cached plugin manifest: \(error.publicLogShape, privacy: .public)") + return nil } } - // MARK: - Fetching - - func ensureManifest(_ intent: RegistryFetchIntent) async { - if let inFlightFetch { - await inFlightFetch.value - return + internal func fetch(policy: RegistryFetchPolicy) async { + while let current = inFlight { + await current.task.value + if policy == .ifStale || current.policy == .mustBeCurrent { return } } - if intent == .ifStale, !needsRefresh { return } - let task = Task { await fetchManifest() } - inFlightFetch = task + let task = Task { await self.load(policy) } + inFlight = InFlightFetch(policy: policy, task: task) await task.value - inFlightFetch = nil - } - - private var needsRefresh: Bool { - guard lastFetchedURL == registryURL, let lastFetchDate else { return true } - return Date().timeIntervalSince(lastFetchDate) > Self.manifestFreshnessWindow } - func fetchManifest(forceRefresh: Bool = false) async { - fetchState = .loading - - let request = makeManifestRequest(forceRefresh: forceRefresh) - if isUsingCustomRegistry { - Self.logger.warning("Using custom plugin registry URL: \(self.registryURL.absoluteString)") + internal func makeRequest(policy: RegistryFetchPolicy) -> URLRequest { + var request = URLRequest(url: manifestURL) + switch policy { + case .ifStale: + request.cachePolicy = .useProtocolCachePolicy + case .mustBeCurrent: + request.cachePolicy = .reloadRevalidatingCacheData } + return request + } + private func load(_ policy: RegistryFetchPolicy) async { + defer { inFlight = nil } do { - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - switch httpResponse.statusCode { - case 200...299: - let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data) - - if decoded.schemaVersion > Self.supportedSchemaVersion { - Self.logger.error( - "Registry schemaVersion \(decoded.schemaVersion) is newer than supported \(Self.supportedSchemaVersion); falling back to cached manifest" - ) - fallbackToCacheOrFail( - message: String(localized: "Plugin registry requires a newer app version") - ) - return - } - - manifest = decoded - - writeCachedManifest(data) - lastFetchDate = Date() - lastFetchedURL = request.url - defaults.set(lastFetchDate, forKey: Self.lastFetchKey) - - fetchState = .loaded - Self.logger.info("Fetched registry manifest with \(decoded.plugins.count) plugin(s)") - - default: - let message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) - Self.logger.error("Registry fetch failed: HTTP \(httpResponse.statusCode) \(message)") - fallbackToCacheOrFail(message: "Server returned HTTP \(httpResponse.statusCode)") + let (data, response) = try await session.data(for: makeRequest(policy: policy)) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + guard (200...299).contains(status) else { + Self.logger.error("Plugin manifest fetch returned HTTP \(status)") + isManifestCurrent = false + return } - } catch is DecodingError { - Self.logger.error("Failed to decode registry manifest") - fallbackToCacheOrFail(message: String(localized: "Failed to parse plugin registry")) + let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data) + manifest = decoded + isManifestCurrent = true + Self.logger.info("Fetched the plugin manifest with \(decoded.plugins.count) plugin(s)") } catch { - Self.logger.error("Registry fetch failed: \(error.localizedDescription)") - fallbackToCacheOrFail(message: error.localizedDescription) - } - } - - func makeManifestRequest(forceRefresh: Bool) -> URLRequest { - var request = URLRequest(url: registryURL) - request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .reloadRevalidatingCacheData - return request - } - - func refreshedPlugin(matching plugin: RegistryPlugin) async -> RegistryPlugin { - await fetchManifest(forceRefresh: true) - return manifest?.plugins.first { $0.id == plugin.id } ?? plugin - } - - private func fallbackToCacheOrFail(message: String) { - if manifest != nil { - fetchState = .loadedFromCache(message) - Self.logger.warning("Using cached registry manifest after fetch failure") - } else { - fetchState = .failed(message) + isManifestCurrent = false + Self.logger.error("Plugin manifest fetch failed: \(error.publicLogShape, privacy: .public)") } } } -enum RegistryFetchIntent: Equatable, Sendable { +internal enum RegistryFetchPolicy: Sendable { + /// Lets the HTTP cache answer while the response is fresh. case ifStale case mustBeCurrent } - -enum RegistryFetchState: Equatable, Sendable { - case idle - case loading - case loaded - case loadedFromCache(String) - case failed(String) -} diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index e7884b09fa..763696bf35 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -1,234 +1,77 @@ import Foundation -enum PluginArchitecture: String, Codable, Sendable { - case arm64 - case x86_64 +internal struct RegistryManifest: Sendable { + internal static let supportedSchemaVersion = 3 - static var current: PluginArchitecture { - #if arch(arm64) - .arm64 - #else - .x86_64 - #endif - } -} - -struct RegistryBinary: Codable, Sendable { - let architecture: PluginArchitecture - let downloadURL: String - let sha256: String - let pluginKitVersion: Int? -} - -struct RegistryManifest: Codable, Sendable { - let schemaVersion: Int - let plugins: [RegistryPlugin] + internal let schemaVersion: Int + internal let plugins: [RegistryPlugin] } -struct RegistryPlugin: Codable, Sendable, Identifiable { - let id: String - let name: String - let version: String - let summary: String - let author: RegistryAuthor - let homepage: String? - let category: RegistryCategory - let databaseTypeIds: [String]? - let binaries: [RegistryBinary] - let minAppVersion: String? - let iconName: String? - let isVerified: Bool - let metadata: RegistryPluginMetadata? - - private let legacyDownloadURL: String? - private let legacySha256: String? - private let legacyMinPluginKitVersion: Int? - +extension RegistryManifest: Decodable { private enum CodingKeys: String, CodingKey { - case id, name, version, summary, author, homepage, category - case databaseTypeIds, binaries, minAppVersion, iconName, isVerified, metadata - case legacyDownloadURL = "downloadURL" - case legacySha256 = "sha256" - case legacyMinPluginKitVersion = "minPluginKitVersion" + case schemaVersion + case plugins } - init(from decoder: Decoder) throws { + internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - version = try container.decode(String.self, forKey: .version) - summary = try container.decode(String.self, forKey: .summary) - author = try container.decode(RegistryAuthor.self, forKey: .author) - homepage = try container.decodeIfPresent(String.self, forKey: .homepage) - category = try container.decode(RegistryCategory.self, forKey: .category) - databaseTypeIds = try container.decodeIfPresent([String].self, forKey: .databaseTypeIds) - minAppVersion = try container.decodeIfPresent(String.self, forKey: .minAppVersion) - iconName = try container.decodeIfPresent(String.self, forKey: .iconName) - isVerified = try container.decodeIfPresent(Bool.self, forKey: .isVerified) ?? false - metadata = try container.decodeIfPresent(RegistryPluginMetadata.self, forKey: .metadata) - legacyDownloadURL = try container.decodeIfPresent(String.self, forKey: .legacyDownloadURL) - legacySha256 = try container.decodeIfPresent(String.self, forKey: .legacySha256) - legacyMinPluginKitVersion = try container.decodeIfPresent(Int.self, forKey: .legacyMinPluginKitVersion) - - if let decodedBinaries = try container.decodeIfPresent([RegistryBinary].self, forKey: .binaries) { - binaries = decodedBinaries - } else if let url = legacyDownloadURL, let hash = legacySha256 { - // A v1 entry has one universal ZIP and no architecture. - binaries = [ - RegistryBinary( - architecture: .arm64, - downloadURL: url, - sha256: hash, - pluginKitVersion: legacyMinPluginKitVersion - ), - RegistryBinary( - architecture: .x86_64, - downloadURL: url, - sha256: hash, - pluginKitVersion: legacyMinPluginKitVersion - ) - ] - } else { - binaries = [] + schemaVersion = try container.decode(Int.self, forKey: .schemaVersion) + guard schemaVersion == Self.supportedSchemaVersion else { + throw DecodingError.dataCorruptedError( + forKey: .schemaVersion, + in: container, + debugDescription: "Schema \(schemaVersion) is not \(Self.supportedSchemaVersion)" + ) } + plugins = try container.decode([RegistryPlugin].self, forKey: .plugins) } } -extension RegistryPlugin { - func resolvedBinary(for arch: PluginArchitecture = .current, kits: ClosedRange) throws -> RegistryBinary { - let forThisArchitecture = binaries.filter { $0.architecture == arch } - let highestInRange = forThisArchitecture - .compactMap { binary -> (binary: RegistryBinary, kit: Int)? in - guard let kit = binary.pluginKitVersion, kits.contains(kit) else { - return nil - } - return (binary, kit) - } - .max { $0.kit < $1.kit } - - if let highestInRange { - return highestInRange.binary - } - - let publishedKits = forThisArchitecture.compactMap(\.pluginKitVersion) - if let oldest = publishedKits.min(), oldest > kits.upperBound { - throw PluginError.appTooOldForPlugin(oldestPublishedKit: oldest, appKit: kits.upperBound) - } - - throw PluginError.noCompatibleBinary - } +internal struct RegistryPlugin: Decodable, Sendable, Identifiable { + internal let id: String + internal let name: String + internal let iconName: String? + internal let databaseTypeIds: [String]? + internal let exportFormatIds: [String]? + internal let releases: [RegistryRelease] } -struct RegistryAuthor: Codable, Sendable { - let name: String - let url: String? +internal struct RegistryRelease: Decodable, Sendable, Equatable { + internal let version: String + internal let build: Int + internal let channel: String + internal let pluginKit: Int + internal let minimumSystemVersion: String + internal let url: String + internal let sha256: String } -enum RegistryCategory: String, Codable, Sendable { - case databaseDriver = "database-driver" - case exportFormat = "export-format" - case importFormat = "import-format" - case other = "other" - - // One entry with a category this app does not know must not fail the whole manifest. - init(from decoder: any Decoder) throws { - let rawValue = try decoder.singleValueContainer().decode(String.self) - self = RegistryCategory(rawValue: rawValue) ?? .other +internal extension RegistryPlugin { + func newestRelease( + channel: UpdateChannel, + kits: ClosedRange, + systemVersion: OperatingSystemVersion + ) throws -> RegistryRelease { + let eligible = releases.filter { release in + channel.accepts(release.channel) && release.runs(on: systemVersion) + } + if let newest = eligible.filter({ kits.contains($0.pluginKit) }).max(by: { $0.build < $1.build }) { + return newest + } + if let oldestNewerKit = eligible.map(\.pluginKit).filter({ $0 > kits.upperBound }).min() { + throw PluginError.appTooOldForPlugin(oldestPublishedKit: oldestNewerKit, appKit: kits.upperBound) + } + throw PluginError.noCompatibleRelease } } -// MARK: - Plugin Metadata (self-describing registry plugins) - -struct RegistryPluginMetadata: Codable, Sendable { - let displayName: String? - let iconName: String? - let defaultPort: Int? - let brandColorHex: String? - let connectionMode: String? - let editorLanguage: String? - let queryLanguageName: String? - let primaryUrlScheme: String? - let parameterStyle: String? - - let requiresAuthentication: Bool? - let supportsForeignKeys: Bool? - let supportsSchemaEditing: Bool? - let supportsDatabaseSwitching: Bool? - let supportsSchemaSwitching: Bool? - let supportsSSH: Bool? - let supportsSSL: Bool? - let supportsImport: Bool? - let supportsExport: Bool? - let supportsHealthMonitor: Bool? - let supportsCascadeDrop: Bool? - let supportsForeignKeyDisable: Bool? - let supportsReadOnlyMode: Bool? - let supportsQueryProgress: Bool? - let requiresReconnectForDatabaseSwitch: Bool? - - let urlSchemes: [String]? - let fileExtensions: [String]? - let systemDatabaseNames: [String]? - let systemSchemaNames: [String]? - let defaultSchemaName: String? - let defaultGroupName: String? - let tableEntityName: String? - let containerEntityName: String? - let defaultPrimaryKeyColumn: String? - let immutableColumns: [String]? - - let navigationModel: String? - let pathFieldRole: String? - let databaseGroupingStrategy: String? - let structureColumnFields: [String]? - let postConnectActions: [RegistryPostConnectAction]? - let additionalConnectionFields: [RegistryConnectionField]? - let explainVariants: [RegistryExplainVariant]? - let sqlDialect: RegistrySqlDialect? - let statementCompletions: [RegistryCompletionEntry]? - let columnTypesByCategory: [String: [String]]? -} - -struct RegistryConnectionField: Codable, Sendable { - let id: String - let label: String - let placeholder: String? - let defaultValue: String? - let fieldType: String? - let section: String? - let options: [RegistryDropdownOption]? -} - -struct RegistryDropdownOption: Codable, Sendable { - let value: String - let label: String -} - -struct RegistryPostConnectAction: Codable, Sendable { - let type: String - let fieldId: String? -} - -struct RegistryExplainVariant: Codable, Sendable { - let name: String - let prefix: String -} - -struct RegistrySqlDialect: Codable, Sendable { - let identifierQuote: String? - let keywords: [String]? - let functions: [String]? - let dataTypes: [String]? - let tableOptions: [String]? - let regexSyntax: String? - let booleanLiteralStyle: String? - let likeEscapeStyle: String? - let paginationStyle: String? - let offsetFetchOrderBy: String? - let requiresBackslashEscaping: Bool? -} - -struct RegistryCompletionEntry: Codable, Sendable { - let label: String - let insertText: String +internal extension RegistryRelease { + func runs(on systemVersion: OperatingSystemVersion) -> Bool { + let parts = minimumSystemVersion.split(separator: ".", omittingEmptySubsequences: false) + let numbers = parts.compactMap { Int($0) } + guard (1...3).contains(parts.count), numbers.count == parts.count else { return false } + let minimum = numbers + Array(repeating: 0, count: 3 - numbers.count) + let running = [systemVersion.majorVersion, systemVersion.minorVersion, systemVersion.patchVersion] + return !running.lexicographicallyPrecedes(minimum) + } } diff --git a/TablePro/Core/Services/Export/ExportFormatCatalog.swift b/TablePro/Core/Services/Export/ExportFormatCatalog.swift index 03f5386b0b..afb640bcf6 100644 --- a/TablePro/Core/Services/Export/ExportFormatCatalog.swift +++ b/TablePro/Core/Services/Export/ExportFormatCatalog.swift @@ -1,8 +1,3 @@ -// -// ExportFormatCatalog.swift -// TablePro -// - import Foundation import TableProPluginKit @@ -17,17 +12,20 @@ internal enum ExportFormatCatalog { "csv", "json", "sql", "xlsx", "md", "parquet", "mql" ] - // The registry manifest names no format id, so the app keeps the one mapping it offers. - internal static let installable = [ - InstallableExportFormat( - formatId: "parquet", - displayName: "Parquet", - registryPluginId: "com.TablePro.ParquetExportPlugin" - ) - ] + internal static func installable(in manifest: RegistryManifest?) -> [InstallableExportFormat] { + (manifest?.plugins ?? []).flatMap { plugin in + (plugin.exportFormatIds ?? []).map { formatId in + InstallableExportFormat(formatId: formatId, displayName: plugin.name, registryPluginId: plugin.id) + } + } + } - internal static func notInstalled(installedFormatIds: Set) -> [InstallableExportFormat] { - installable.filter { !installedFormatIds.contains($0.formatId) } + @MainActor + internal static func notInstalled( + installedFormatIds: Set, + manifest: RegistryManifest? = RegistryClient.shared.manifest + ) -> [InstallableExportFormat] { + installable(in: manifest).filter { !installedFormatIds.contains($0.formatId) } } internal static func available( diff --git a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift index 68e3bead5f..ab012e9bb8 100644 --- a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift +++ b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -35,7 +35,7 @@ internal enum PostLaunchWork { guard !AppStorageEnvironment.shared.isIsolated else { return } - Task { await RegistryClient.shared.ensureManifest(.ifStale) } + Task { await RegistryClient.shared.fetch(policy: .ifStale) } /// The retired per-tab split name left one permanent defaults record per query tab ever /// opened, and AppKit offers no way to forget one. Here rather than in the delegate because diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index b95187119f..90dab53954 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -78322,6 +78322,7 @@ } }, "Failed to parse plugin registry" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -113032,6 +113033,9 @@ } } } + }, + "No build of this plugin works with this version of TablePro." : { + }, "No cells changed." : { "localizations" : { @@ -131482,6 +131486,7 @@ } }, "Plugin does not contain a compatible binary for this architecture" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -131802,6 +131807,7 @@ } }, "Plugin registry requires a newer app version" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 46192efe26..f64a83a6e2 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -1,7 +1,3 @@ -// -// InstalledPluginsView.swift -// TablePro - import AppKit import SwiftUI import TableProPluginKit @@ -48,7 +44,7 @@ struct InstalledPluginsView: View { } } .task { - await registryClient.ensureManifest(.ifStale) + await registryClient.fetch(policy: .ifStale) } .onDrop(of: [.fileURL], isTargeted: nil) { providers in guard let provider = providers.first, @@ -376,8 +372,8 @@ struct InstalledPluginsView: View { .font(.subheadline) .foregroundStyle(.secondary) - if let registryPlugin = pluginManager.registryUpdate(for: selected.id) { - updateActionView(for: selected, registryPlugin: registryPlugin) + if let update = pluginManager.registryUpdate(for: selected.id) { + updateActionView(for: selected, registryPlugin: update.plugin, version: update.release.version) } if !selected.pluginDescription.isEmpty { @@ -464,7 +460,7 @@ struct InstalledPluginsView: View { // MARK: - Update @ViewBuilder - private func updateActionView(for plugin: PluginEntry, registryPlugin: RegistryPlugin) -> some View { + private func updateActionView(for plugin: PluginEntry, registryPlugin: RegistryPlugin, version: String) -> some View { if let progress = installTracker.state(for: plugin.id) { switch progress.phase { case .downloading(let fraction): @@ -509,7 +505,7 @@ struct InstalledPluginsView: View { } case .completed: Label( - String(format: String(localized: "Updated to v%@"), registryPlugin.version), + String(format: String(localized: "Updated to v%@"), version), systemImage: "checkmark.circle.fill" ) .foregroundStyle(.green) @@ -521,7 +517,7 @@ struct InstalledPluginsView: View { } } else { HStack(spacing: 8) { - Text(String(format: String(localized: "v%@ available"), registryPlugin.version)) + Text(String(format: String(localized: "v%@ available"), version)) .font(.callout) .foregroundStyle(.blue) Button(String(localized: "Update")) { updatePlugin(registryPlugin) } diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift index e536e73280..9335d7627b 100644 --- a/TableProTests/Core/Export/ExportFormatCatalogTests.swift +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -1,8 +1,3 @@ -// -// ExportFormatCatalogTests.swift -// TableProTests -// - import Foundation import TableProPluginKit import Testing @@ -117,4 +112,38 @@ struct ExportFormatCatalogTests { #expect(forwards == backwards) #expect(forwards == ["csv", "sql", "parquet", "zebra"]) } + + private func manifest(_ plugins: [RegistryPlugin]) -> RegistryManifest { + RegistryManifest(schemaVersion: RegistryManifest.supportedSchemaVersion, plugins: plugins) + } + + private func registryPlugin(id: String, name: String, exportFormatIds: [String]?) -> RegistryPlugin { + RegistryPlugin( + id: id, + name: name, + iconName: nil, + databaseTypeIds: [], + exportFormatIds: exportFormatIds, + releases: [] + ) + } + + @Test("The manifest's export-format ids are what the catalog offers to install") + @MainActor + func installableComesFromTheManifest() { + let listed = manifest([ + registryPlugin(id: "com.TablePro.OracleDriver", name: "Oracle Driver", exportFormatIds: nil), + registryPlugin(id: "com.TablePro.ParquetExportPlugin", name: "Parquet Export", exportFormatIds: ["parquet"]) + ]) + + #expect(ExportFormatCatalog.installable(in: listed) == [ + InstallableExportFormat( + formatId: "parquet", + displayName: "Parquet Export", + registryPluginId: "com.TablePro.ParquetExportPlugin" + ) + ]) + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: ["parquet"], manifest: listed).isEmpty) + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: [], manifest: nil).isEmpty) + } } diff --git a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift index ae3ac98117..a52ccda553 100644 --- a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift +++ b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift @@ -1,12 +1,3 @@ -// -// DriverUnavailabilityTests.swift -// TableProTests -// -// An imported connection whose database type nothing answers, a plugin the user turned off and a -// plugin that failed to load all used to surface as one message that blamed a missing plugin, so -// the reporter installed the plugin they already had and saw nothing change. -// - import Foundation @testable import TablePro import TableProPluginKit @@ -47,11 +38,9 @@ private final class UnreachableRegistryProtocol: URLProtocol, @unchecked Sendabl @MainActor struct DriverUnavailabilityTests { private let managerSuite: EphemeralDefaults - private let registrySuite: EphemeralDefaults init() throws { managerSuite = try EphemeralDefaults() - registrySuite = try EphemeralDefaults() } private func makeManager() throws -> (manager: PluginManager, root: URL) { @@ -98,15 +87,12 @@ struct DriverUnavailabilityTests { ) } - private func makeUnreachableRegistry(root: URL) throws -> RegistryClient { + private func makeUnreachableRegistry() throws -> RegistryClient { UnreachableRegistryProtocol.reset() let config = URLSessionConfiguration.ephemeral config.protocolClasses = [UnreachableRegistryProtocol.self] - return RegistryClient( - userDefaults: registrySuite.defaults, - session: URLSession(configuration: config), - manifestCacheURL: root.appendingPathComponent("registry-manifest.json") - ) + let manifestURL = try #require(URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json")) + return RegistryClient(manifestURL: manifestURL, session: URLSession(configuration: config)) } @Test("A type no plugin, curated entry or registry entry answers is unrecognized") @@ -127,16 +113,13 @@ struct DriverUnavailabilityTests { let typeId = "FutureDB-\(UUID().uuidString)" let json = """ { - "schemaVersion": 2, + "schemaVersion": 3, + "retainFromPluginKit": 39, "plugins": [{ "id": "com.example.future", "name": "Future Driver", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", "databaseTypeIds": ["\(typeId)"], - "binaries": [] + "releases": [] }] } """ @@ -227,7 +210,7 @@ struct DriverUnavailabilityTests { defer { try? FileManager.default.removeItem(at: root) } manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server")] manager.rejectedPlugins = [makeRejection(typeId: "SQL Server", isOutdated: false, reason: "bad signature")] - let registry = try makeUnreachableRegistry(root: root) + let registry = try makeUnreachableRegistry() try await manager.prepareForConnecting(to: .mssql, registryClient: registry) @@ -240,16 +223,13 @@ struct DriverUnavailabilityTests { func rejectionIdentityComesFromTheRegistry() throws { let json = """ { - "schemaVersion": 2, + "schemaVersion": 3, + "retainFromPluginKit": 39, "plugins": [{ "id": "com.example.eager", "name": "Eager Driver", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", "databaseTypeIds": ["EagerDB"], - "binaries": [] + "releases": [] }] } """ @@ -264,7 +244,7 @@ struct DriverUnavailabilityTests { func failedInstallSurfacesItsReason() async throws { let (manager, root) = try makeManager() defer { try? FileManager.default.removeItem(at: root) } - let registry = try makeUnreachableRegistry(root: root) + let registry = try makeUnreachableRegistry() do { try await manager.prepareForConnecting(to: .mssql, registryClient: registry) diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index e9eb0b44f5..3dbfc2af2f 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -7,23 +7,29 @@ import Testing struct PluginManagerReconciliationTests { private func makeManifest(pluginIds: [String]) -> RegistryManifest { - let plugins = pluginIds.map { id -> RegistryPlugin in - let json = """ - { - "id": "\(id)", - "name": "Test Plugin", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", - "binaries": [ - {"architecture": "arm64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": 13} - ] - } - """ - return try! JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + let plugins = pluginIds.map { id in + RegistryPlugin( + id: id, + name: "Test Plugin", + iconName: nil, + databaseTypeIds: nil, + exportFormatIds: nil, + releases: [makeRelease(kit: 13)] + ) } - return RegistryManifest(schemaVersion: 2, plugins: plugins) + return RegistryManifest(schemaVersion: RegistryManifest.supportedSchemaVersion, plugins: plugins) + } + + private func makeRelease(kit: Int) -> RegistryRelease { + RegistryRelease( + version: "0.82.0", + build: 820_000 + kit, + channel: "stable", + pluginKit: kit, + minimumSystemVersion: "13.0", + url: "https://x", + sha256: "deadbeef" + ) } private func makeRejected( @@ -45,22 +51,14 @@ struct PluginManagerReconciliationTests { } private func makeRegistryPlugin(id: String = "com.example.driver", kitVersions: [Int]) throws -> RegistryPlugin { - let arch = PluginArchitecture.current.rawValue - let binaries = kitVersions - .map { "{\"architecture\": \"\(arch)\", \"downloadURL\": \"https://x\", \"sha256\": \"deadbeef\", \"pluginKitVersion\": \($0)}" } - .joined(separator: ",") - let json = """ - { - "id": "\(id)", - "name": "Test Plugin", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", - "binaries": [\(binaries)] - } - """ - return try JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + RegistryPlugin( + id: id, + name: "Test Plugin", + iconName: nil, + databaseTypeIds: nil, + exportFormatIds: nil, + releases: kitVersions.map { makeRelease(kit: $0) } + ) } private func kind(_ action: RejectedPluginAction) -> String { @@ -213,7 +211,7 @@ struct PluginManagerReconciliationTests { @Test("incompatible-build errors are permanent reconciliation failures") func permanentFailuresClassified() { - #expect(PluginError.noCompatibleBinary.isPermanentReconciliationFailure) + #expect(PluginError.noCompatibleRelease.isPermanentReconciliationFailure) #expect(PluginError.incompatibleVersion(required: 15, current: 14).isPermanentReconciliationFailure) #expect(PluginError.appTooOldForPlugin(oldestPublishedKit: 15, appKit: 14).isPermanentReconciliationFailure) } diff --git a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift deleted file mode 100644 index b63b849135..0000000000 --- a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift +++ /dev/null @@ -1,216 +0,0 @@ -import Foundation -import Testing -@testable import TablePro - -struct RegistryBinarySelectionTests { - - private func makePlugin(binaries: [RegistryBinary]) -> RegistryPlugin { - let payload: [String: Any] = [ - "id": "com.example.driver", - "name": "Example", - "version": "1.0.0", - "summary": "test", - "author": ["name": "Tester"], - "category": "database-driver", - "binaries": binaries.map { binary -> [String: Any] in - var dict: [String: Any] = [ - "architecture": binary.architecture.rawValue, - "downloadURL": binary.downloadURL, - "sha256": binary.sha256 - ] - if let kit = binary.pluginKitVersion { dict["pluginKitVersion"] = kit } - return dict - } - ] - let data = try! JSONSerialization.data(withJSONObject: payload) - return try! JSONDecoder().decode(RegistryPlugin.self, from: data) - } - - @Test("exact pluginKitVersion + arch match wins") - func exactMatchSelected() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/13", sha256: "aaa", pluginKitVersion: 13), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - #expect(resolved.downloadURL == "https://a/13") - } - - @Test("nil pluginKitVersion is no longer a universal fallback") - func nilPluginKitVersionNotMatched() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - } - } - - @Test("valid binary is still selected when a nil-kit binary is also present (#1322 DynamoDB)") - func validBinaryFoundAlongsideNilKit() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/14", sha256: "def", pluginKitVersion: 14) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 14...14) - #expect(resolved.downloadURL == "https://a/14") - } - - @Test("resilient older-kit binary is served to a newer app without re-publish") - func forwardCompatibleBinarySelected() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/18", sha256: "aaa", pluginKitVersion: 18) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 18...19) - #expect(resolved.downloadURL == "https://a/18") - } - - @Test("highest binary within the compatible range wins") - func highestInRangeSelected() throws { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/18", sha256: "aaa", pluginKitVersion: 18), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/19", sha256: "bbb", pluginKitVersion: 19), - RegistryBinary(architecture: .arm64, downloadURL: "https://a/20", sha256: "ccc", pluginKitVersion: 20) - ]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 18...20) - #expect(resolved.downloadURL == "https://a/20") - } - - @Test("binary below the floor is rejected") - func belowFloorRejected() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/17", sha256: "aaa", pluginKitVersion: 17) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 18...18) - } - } - - @Test("binary built for a newer app is rejected") - func aboveCurrentRejected() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/19", sha256: "aaa", pluginKitVersion: 19) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 18...18) - } - } - - @Test("throws noCompatibleBinary when no arch match") - func noArchitectureMatch() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .x86_64, downloadURL: "https://intel", sha256: "x", pluginKitVersion: 13) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - } - } - - @Test("throws noCompatibleBinary when arch matches but no kit version match and no legacy") - func noKitVersionMatchNoLegacy() { - let plugin = makePlugin(binaries: [ - RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) - ]) - #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, kits: 13...13) - } - } - - @Test("v1 manifest with flat downloadURL/sha256 decodes to arm64 + x86_64 binaries") - func v1FlatFieldsBackwardCompat() throws { - let json = #""" - { - "id": "com.example.legacy", - "name": "Legacy", - "version": "1.0.0", - "summary": "v1 entry", - "author": {"name": "Tester"}, - "category": "database-driver", - "downloadURL": "https://legacy.example/plugin.zip", - "sha256": "deadbeef", - "minPluginKitVersion": 11 - } - """# - let plugin = try JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) - #expect(plugin.binaries.count == 2) - #expect(plugin.binaries.contains { $0.architecture == .arm64 && $0.pluginKitVersion == 11 }) - #expect(plugin.binaries.contains { $0.architecture == .x86_64 && $0.pluginKitVersion == 11 }) - } -} - -struct RegistryStaleAppTests { - - private func makePlugin(kits: [Int]) -> RegistryPlugin { - let payload: [String: Any] = [ - "id": "com.example.driver", - "name": "Example", - "version": "1.0.0", - "summary": "test", - "author": ["name": "Tester"], - "category": "database-driver", - "binaries": kits.map { kit -> [String: Any] in - [ - "architecture": PluginArchitecture.arm64.rawValue, - "downloadURL": "https://a/\(kit)", - "sha256": "sha\(kit)", - "pluginKitVersion": kit - ] - } - ] - let data = try! JSONSerialization.data(withJSONObject: payload) - return try! JSONDecoder().decode(RegistryPlugin.self, from: data) - } - - // 2026-09-13: apps on kits 19 and 20 were told their architecture was unsupported when the oldest published kit was 21. - @Test("An app below every published binary is named as the stale side, not the architecture") - func staleAppIsReportedAsStale() { - let plugin = makePlugin(kits: [21, 25, 30]) - - #expect { - _ = try plugin.resolvedBinary(for: .arm64, kits: 19...19) - } throws: { error in - guard case PluginError.appTooOldForPlugin(let oldest, let appKit) = error else { return false } - return oldest == 21 && appKit == 19 - } - } - - @Test("A binary above the app's kit is skipped for a lower one that fits") - func picksTheHighestBinaryTheAppCanLoad() throws { - let plugin = makePlugin(kits: [21, 25, 30]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 19...25) - - #expect(resolved.downloadURL == "https://a/25") - } - - @Test("A plugin the app is new enough for still resolves") - func newestAppResolvesNewestBinary() throws { - let plugin = makePlugin(kits: [21, 25, 30]) - let resolved = try plugin.resolvedBinary(for: .arm64, kits: 19...30) - - #expect(resolved.downloadURL == "https://a/30") - } - - @Test("A binary too old for the app is not reported as a stale app") - func binaryBelowTheMinimumKeepsTheOriginalError() { - let plugin = makePlugin(kits: [12]) - - #expect { - _ = try plugin.resolvedBinary(for: .arm64, kits: 19...30) - } throws: { error in - guard case PluginError.noCompatibleBinary = error else { return false } - return true - } - } - - @Test("A missing architecture is still an architecture problem") - func missingArchitectureKeepsTheOriginalError() { - let plugin = makePlugin(kits: [30]) - - #expect { - _ = try plugin.resolvedBinary(for: .x86_64, kits: 19...30) - } throws: { error in - guard case PluginError.noCompatibleBinary = error else { return false } - return true - } - } -} diff --git a/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift b/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift deleted file mode 100644 index f7091761be..0000000000 --- a/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// RegistryCategoryDecodingTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct RegistryCategoryDecodingTests { - @Test("An entry with a category this app does not know decodes as other, and the rest still load") - func unknownCategoryDecodesAsOther() throws { - let json = """ - {"schemaVersion": 2, "plugins": [ - {"id": "com.example.theme", "name": "Theme", "version": "1.0.0", "summary": "test", - "author": {"name": "Tester"}, "category": "theme", "binaries": []}, - {"id": "com.example.driver", "name": "Driver", "version": "1.0.0", "summary": "test", - "author": {"name": "Tester"}, "category": "database-driver", "binaries": []} - ]} - """ - let manifest = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) - - #expect(manifest.plugins.map(\.category) == [.other, .databaseDriver]) - } -} diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index bf2895efb6..399e11b578 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -53,7 +53,11 @@ struct RegistryClientFreshnessTests { private static let testTypeId = "TestFreshnessDB" - private func manifestData(includingTestPlugin: Bool) -> Data { + private func manifestURL() throws -> URL { + try #require(URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json")) + } + + private func manifestData(includingTestPlugin: Bool, schemaVersion: Int = 3) -> Data { var plugins = "" if includingTestPlugin { let kit = PluginManager.currentPluginKitVersion + 1 @@ -61,19 +65,15 @@ struct RegistryClientFreshnessTests { { "id": "com.test.freshness-driver", "name": "Freshness Test Driver", - "version": "1.0.0", - "summary": "test", - "author": {"name": "Tester"}, - "category": "database-driver", "databaseTypeIds": ["\(Self.testTypeId)"], - "binaries": [ - {"architecture": "arm64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": \(kit)}, - {"architecture": "x86_64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": \(kit)} - ] + "releases": [{ + "version": "0.82.0", "build": 820099, "channel": "stable", "pluginKit": \(kit), + "minimumSystemVersion": "13.0", "url": "https://x", "sha256": "deadbeef" + }] } """ } - return Data("{\"schemaVersion\": 2, \"plugins\": [\(plugins)]}".utf8) + return Data("{\"schemaVersion\": \(schemaVersion), \"retainFromPluginKit\": 39, \"plugins\": [\(plugins)]}".utf8) } private func makeEnvironment( @@ -83,106 +83,124 @@ struct RegistryClientFreshnessTests { .appendingPathComponent("registry-freshness-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let cacheURL = tempDir.appendingPathComponent("registry-manifest.json") + let url = try manifestURL() + let cache = URLCache(memoryCapacity: 4_000_000, diskCapacity: 0, directory: nil) if let cachedManifest { - try cachedManifest.write(to: cacheURL) + let response = try #require(HTTPURLResponse( + url: url, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )) + cache.storeCachedResponse(CachedURLResponse(response: response, data: cachedManifest), for: URLRequest(url: url)) } - let defaults = suite.defaults - let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockRegistryProtocol.self] - let session = URLSession(configuration: config) - - let client = RegistryClient( - userDefaults: defaults, - session: session, - manifestCacheURL: cacheURL - ) - return (client, defaults, tempDir) + config.urlCache = cache + let client = RegistryClient(manifestURL: url, session: URLSession(configuration: config)) + return (client, suite.defaults, tempDir) } - @Test("non-forced manifest request revalidates with the origin instead of trusting the local cache") - func nonForcedRequestRevalidates() throws { + @Test("The client asks for the manifest at the URL it was given") + func requestsTheInjectedURL() throws { let (client, _, _) = try makeEnvironment() - let request = client.makeManifestRequest(forceRefresh: false) - #expect(request.cachePolicy == .reloadRevalidatingCacheData) + + #expect(client.makeRequest(policy: .ifStale).url == client.manifestURL) + #expect(client.manifestURL.host() == "registry.invalid") + } + + @Test("The shipped client reads the v3 manifest from updates.tablepro.app with a 20 MB disk cache") + func defaultManifestURLAndSession() { + let config = RegistryClient.makeDefaultSession().configuration + + #expect(RegistryClient.defaultManifestURL.absoluteString == "https://updates.tablepro.app/plugins/v3.json") + #expect(config.timeoutIntervalForRequest == 15) + #expect((config.urlCache?.diskCapacity ?? 0) >= 20_000_000) } - @Test("forced manifest request bypasses the local cache entirely") - func forcedRequestIgnoresCache() throws { + @Test("ifStale lets the HTTP cache answer, mustBeCurrent revalidates with the origin") + func cachePolicies() throws { let (client, _, _) = try makeEnvironment() - let request = client.makeManifestRequest(forceRefresh: true) - #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + + #expect(client.makeRequest(policy: .ifStale).cachePolicy == .useProtocolCachePolicy) + #expect(client.makeRequest(policy: .mustBeCurrent).cachePolicy == .reloadRevalidatingCacheData) + } + + @Test("A cached v3 response is decoded when the client starts") + func cachedManifestDecodedAtInit() throws { + let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) + + #expect(client.manifest?.plugins.map(\.id) == ["com.test.freshness-driver"]) + #expect(!client.isManifestCurrent) } - @Test("ifStale skips the network inside the freshness window") - func ifStaleThrottlesInsideFreshnessWindow() async throws { + @Test("A cached v2 response is ignored") + func cachedSchemaTwoIgnored() throws { + let (client, _, _) = try makeEnvironment( + cachedManifest: manifestData(includingTestPlugin: false, schemaVersion: 2) + ) + + #expect(client.manifest == nil) + } + + @Test("concurrent callers share one in-flight fetch") + func concurrentCallersShareOneFetch() async throws { MockRegistryProtocol.reset(responses: [ (200, manifestData(includingTestPlugin: false)), (200, manifestData(includingTestPlugin: false)) ]) let (client, _, _) = try makeEnvironment() - await client.ensureManifest(.ifStale) - #expect(MockRegistryProtocol.requestCount == 1) - #expect(client.fetchState == .loaded) + async let first: Void = client.fetch(policy: .ifStale) + async let second: Void = client.fetch(policy: .ifStale) + _ = await (first, second) - await client.ensureManifest(.ifStale) #expect(MockRegistryProtocol.requestCount == 1) + #expect(client.isManifestCurrent) } - @Test("mustBeCurrent revalidates even inside the freshness window") - func mustBeCurrentAlwaysRevalidates() async throws { + @Test("mustBeCurrent fetches again after an ifStale fetch and replaces the manifest") + func mustBeCurrentFetchesAgain() async throws { MockRegistryProtocol.reset(responses: [ (200, manifestData(includingTestPlugin: false)), (200, manifestData(includingTestPlugin: true)) ]) let (client, _, _) = try makeEnvironment() - await client.ensureManifest(.ifStale) - #expect(MockRegistryProtocol.requestCount == 1) + await client.fetch(policy: .ifStale) + #expect(client.manifest?.plugins.isEmpty == true) - await client.ensureManifest(.mustBeCurrent) + await client.fetch(policy: .mustBeCurrent) #expect(MockRegistryProtocol.requestCount == 2) #expect(client.manifest?.plugins.count == 1) } - @Test("concurrent callers share one in-flight fetch") - func concurrentCallersShareOneFetch() async throws { - MockRegistryProtocol.reset(responses: [ - (200, manifestData(includingTestPlugin: false)), - (200, manifestData(includingTestPlugin: false)) - ]) - let (client, _, _) = try makeEnvironment() + @Test("A failed refresh keeps the cached manifest and is not current") + func failedRefreshKeepsCachedManifest() async throws { + MockRegistryProtocol.reset(responses: [(500, Data())]) + let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) - async let first: Void = client.ensureManifest(.ifStale) - async let second: Void = client.ensureManifest(.ifStale) - _ = await (first, second) + await client.fetch(policy: .mustBeCurrent) - #expect(MockRegistryProtocol.requestCount == 1) + #expect(client.manifest?.plugins.count == 1) + #expect(!client.isManifestCurrent) } - @Test("failed refresh keeps the cached manifest and reports it as cached") - func failedRefreshKeepsCachedManifest() async throws { - MockRegistryProtocol.reset(responses: [(500, Data())]) + @Test("A v2 document from the server is refused and the cached manifest stays") + func fetchedSchemaTwoRefused() async throws { + MockRegistryProtocol.reset(responses: [(200, manifestData(includingTestPlugin: false, schemaVersion: 2))]) let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) - #expect(client.manifest != nil) - await client.ensureManifest(.mustBeCurrent) + await client.fetch(policy: .mustBeCurrent) #expect(client.manifest?.plugins.count == 1) - guard case .loadedFromCache = client.fetchState else { - Issue.record("Expected loadedFromCache, got \(client.fetchState)") - return - } + #expect(!client.isManifestCurrent) } @Test("stale manifest miss triggers a fresh lookup before reporting not found") func staleManifestMissRevalidatesBeforeNotFound() async throws { let stale = manifestData(includingTestPlugin: false) let fresh = manifestData(includingTestPlugin: true) - MockRegistryProtocol.reset(responses: [(200, stale), (200, fresh), (200, fresh)]) + MockRegistryProtocol.reset(responses: [(200, stale), (200, fresh)]) let (client, defaults, tempDir) = try makeEnvironment(cachedManifest: stale) let manager = PluginManager( @@ -198,6 +216,7 @@ struct RegistryClientFreshnessTests { ) { _ in } Issue.record("Expected install to fail at compatibility validation") } catch let error as PluginError { + #expect(MockRegistryProtocol.requestCount == 2) if case .notFound = error { Issue.record("Lookup missed a plugin the forced revalidation should have found") } @@ -236,7 +255,7 @@ struct RegistryClientFreshnessTests { @Test("an offered export format is found in the registry by its plugin id") func exportFormatResolvesByRegistryId() async throws { let fresh = manifestData(includingTestPlugin: true) - MockRegistryProtocol.reset(responses: [(200, fresh), (200, fresh), (200, fresh)]) + MockRegistryProtocol.reset(responses: [(200, fresh)]) let (client, defaults, tempDir) = try makeEnvironment() let manager = PluginManager( userDefaults: defaults, diff --git a/TableProTests/Core/Plugins/RegistryClientURLTests.swift b/TableProTests/Core/Plugins/RegistryClientURLTests.swift deleted file mode 100644 index 824b9785ab..0000000000 --- a/TableProTests/Core/Plugins/RegistryClientURLTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// RegistryClientURLTests.swift -// TableProTests -// - -import Foundation -import TableProPluginKit -import Testing -@testable import TablePro - -@MainActor -struct RegistryClientURLTests { - private let suite: EphemeralDefaults - private let customURLKey = RegistryClient.customRegistryURLKey - - init() throws { - suite = try EphemeralDefaults() - } - - private func makeClient(customURL: String) -> RegistryClient { - let defaults = suite.defaults - defaults.set(customURL, forKey: customURLKey) - let cacheURL = FileManager.default.temporaryDirectory - .appendingPathComponent("registry-url-tests-\(UUID().uuidString)", isDirectory: true) - .appendingPathComponent("registry-manifest.json") - return RegistryClient(userDefaults: defaults, manifestCacheURL: cacheURL) - } - - @Test("customRegistryURLKey has expected value") - func customURLKeyConstant() { - #expect(RegistryClient.customRegistryURLKey == "com.TablePro.customRegistryURL") - } - - @Test("setting a custom URL via UserDefaults is supported") - func settingCustomURL() throws { - let testURL = "https://custom.example.com/registry/manifest.json" - let client = makeClient(customURL: testURL) - - #expect(client.isUsingCustomRegistry) - let requested = client.makeManifestRequest(forceRefresh: false).url - #expect(requested?.absoluteString == testURL) - #expect(requested?.host() == "custom.example.com") - } - - @Test("session has correct timeout configuration") - func sessionConfiguration() { - let config = RegistryClient.makeDefaultSession().configuration - - #expect(config.timeoutIntervalForRequest == 15) - } - - @Test("invalid custom URL string does not crash") - func invalidCustomURLFallsBackSafely() throws { - let client = makeClient(customURL: "not a valid url %%%") - - #expect(client.makeManifestRequest(forceRefresh: false).url != nil) - #expect(client.session.configuration.timeoutIntervalForRequest == 15) - } -} diff --git a/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift b/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift new file mode 100644 index 0000000000..bed025ce38 --- /dev/null +++ b/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift @@ -0,0 +1,145 @@ +import Foundation +@testable import TablePro +import Testing + +struct RegistryReleaseSelectionTests { + private static let ventura = OperatingSystemVersion(majorVersion: 13, minorVersion: 0, patchVersion: 0) + + private func release( + build: Int, + kit: Int, + channel: String = "stable", + minimumSystemVersion: String = "13.0" + ) -> RegistryRelease { + RegistryRelease( + version: "0.0.\(build)", + build: build, + channel: channel, + pluginKit: kit, + minimumSystemVersion: minimumSystemVersion, + url: "https://example.com/\(build).zip", + sha256: String(repeating: "a", count: 64) + ) + } + + private func plugin(_ releases: [RegistryRelease]) -> RegistryPlugin { + RegistryPlugin( + id: "com.example.driver", + name: "Example Driver", + iconName: nil, + databaseTypeIds: ["Example"], + exportFormatIds: nil, + releases: releases + ) + } + + private func newest( + _ releases: [RegistryRelease], + channel: UpdateChannel = .stable, + kits: ClosedRange = 39...40, + systemVersion: OperatingSystemVersion = ventura + ) throws -> RegistryRelease { + try plugin(releases).newestRelease(channel: channel, kits: kits, systemVersion: systemVersion) + } + + @Test("The highest build in the kit range wins") + func highestBuildInRange() throws { + let picked = try newest([release(build: 820_099, kit: 39), release(build: 820_199, kit: 40)]) + + #expect(picked.build == 820_199) + } + + @Test("A release with a kit above or below the range is skipped") + func kitOutsideRangeSkipped() throws { + let picked = try newest([ + release(build: 830_099, kit: 41), + release(build: 820_099, kit: 39), + release(build: 810_099, kit: 38) + ]) + + #expect(picked.build == 820_099) + } + + @Test("A stable app skips a beta release and a beta app takes it") + func betaFollowsTheAppChannel() throws { + let releases = [release(build: 820_099, kit: 39), release(build: 830_001, kit: 39, channel: "beta")] + + #expect(try newest(releases, channel: .stable).build == 820_099) + #expect(try newest(releases, channel: .beta).build == 830_001) + } + + @Test("A release in a channel the app does not know is skipped, and the manifest still decodes") + func unknownChannelSkipped() throws { + let json = """ + {"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": [{ + "id": "com.example.driver", "name": "Example Driver", "databaseTypeIds": ["Example"], + "releases": [ + {"version": "0.83.0-nightly.1", "build": 830050, "channel": "nightly", "pluginKit": 39, + "minimumSystemVersion": "13.0", "url": "https://example.com/n.zip", "sha256": "\(String(repeating: "b", count: 64))"}, + {"version": "0.82.0", "build": 820099, "channel": "stable", "pluginKit": 39, + "minimumSystemVersion": "13.0", "url": "https://example.com/s.zip", "sha256": "\(String(repeating: "c", count: 64))"} + ] + }]} + """ + let manifest = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) + let entry = try #require(manifest.plugins.first) + let picked = try entry.newestRelease(channel: .beta, kits: 39...39, systemVersion: Self.ventura) + + #expect(entry.releases.count == 2) + #expect(picked.build == 820_099) + } + + @Test("A release that needs a newer macOS than the running one is skipped") + func newerSystemSkipped() throws { + let picked = try newest([ + release(build: 820_199, kit: 39, minimumSystemVersion: "14.0"), + release(build: 820_099, kit: 39, minimumSystemVersion: "13.0") + ]) + + #expect(picked.build == 820_099) + } + + @Test("A three-part minimumSystemVersion compares part by part") + func patchLevelSystemVersion() throws { + let candidates = [release(build: 820_199, kit: 39, minimumSystemVersion: "13.4.1")] + let ventura5 = OperatingSystemVersion(majorVersion: 13, minorVersion: 5, patchVersion: 0) + + #expect(try newest(candidates, systemVersion: ventura5).build == 820_199) + #expect(throws: PluginError.self) { try newest(candidates, systemVersion: Self.ventura) } + } + + @Test("Only releases above the app's kit report that the app is too old") + func appTooOldForPlugin() { + #expect { + _ = try newest([release(build: 840_099, kit: 42), release(build: 830_099, kit: 41)]) + } throws: { error in + guard case PluginError.appTooOldForPlugin(let oldest, let appKit) = error else { return false } + return oldest == 41 && appKit == 40 + } + } + + @Test("Nothing selectable and nothing newer reports no compatible release") + func noCompatibleRelease() { + let candidates = [ + release(build: 810_099, kit: 38), + release(build: 830_001, kit: 41, channel: "beta"), + release(build: 830_099, kit: 41, minimumSystemVersion: "15.0") + ] + + #expect { + _ = try newest(candidates, channel: .stable) + } throws: { error in + guard case PluginError.noCompatibleRelease = error else { return false } + return true + } + } + + @Test("A v2 document is refused") + func schemaTwoRefused() { + let json = #"{"schemaVersion": 2, "plugins": []}"# + + #expect(throws: DecodingError.self) { + _ = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) + } + } +} diff --git a/TableProTests/Core/Storage/StandardDomainIsolationTests.swift b/TableProTests/Core/Storage/StandardDomainIsolationTests.swift index 04a1938a05..076da8caa3 100644 --- a/TableProTests/Core/Storage/StandardDomainIsolationTests.swift +++ b/TableProTests/Core/Storage/StandardDomainIsolationTests.swift @@ -1,8 +1,3 @@ -// -// StandardDomainIsolationTests.swift -// TableProTests -// - import AppKit import Foundation @testable import TableProUtilities @@ -46,20 +41,6 @@ struct StandardDomainIsolationTests { #expect(reader.entries(connectionId: connectionId).map(\.name) == ["users"]) } - @Test("The registry client built with its defaults migrates the app's store") - func registryClientFollowsTheStorageEnvironment() { - let legacyKey = "registryETag" - appDefaults.set("stale", forKey: legacyKey) - defer { appDefaults.removeObject(forKey: legacyKey) } - let cacheURL = FileManager.default.temporaryDirectory - .appendingPathComponent("app-store-defaults-\(UUID().uuidString)", isDirectory: true) - .appendingPathComponent("registry-manifest.json") - - _ = RegistryClient(manifestCacheURL: cacheURL) - - #expect(appDefaults.object(forKey: legacyKey) == nil) - } - @Test("The privilege scope outline saves its columns only under an autosave name") func scopeOutlineSavesColumnsOnlyUnderAName() throws { let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) diff --git a/TableProTests/Helpers/OfflineRegistry.swift b/TableProTests/Helpers/OfflineRegistry.swift index 2807d8fbce..70c290acac 100644 --- a/TableProTests/Helpers/OfflineRegistry.swift +++ b/TableProTests/Helpers/OfflineRegistry.swift @@ -25,49 +25,34 @@ final class OfflineRegistryProtocol: URLProtocol, @unchecked Sendable { } Self.lock.withLock { Self.requestedURLs.append(url) } client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: Data(#"{"schemaVersion": 2, "plugins": []}"#.utf8)) + client?.urlProtocol(self, didLoad: Data(#"{"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": []}"#.utf8)) client?.urlProtocolDidFinishLoading(self) } } -/// A registry client for a view that fetches on appear. Its own URL, defaults and cache file keep one -/// test's requests countable apart from another's, and its session never reaches the network. +/// A registry client for a view that fetches on appear. Its own URL keeps one test's requests +/// countable apart from another's, and its session never reaches the network. @MainActor final class OfflineRegistry { let client: RegistryClient let manifestURL: URL - private let defaults: EphemeralDefaults - private let directory: URL init() throws { - guard let manifestURL = URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins.json") else { + guard let manifestURL = URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json") else { throw URLError(.badURL) } self.manifestURL = manifestURL - directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OfflineRegistry-\(UUID().uuidString)", isDirectory: true) - defaults = try EphemeralDefaults() - defaults.defaults.set(manifestURL.absoluteString, forKey: RegistryClient.customRegistryURLKey) let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [OfflineRegistryProtocol.self] - client = RegistryClient( - userDefaults: defaults.defaults, - session: URLSession(configuration: configuration), - manifestCacheURL: directory.appendingPathComponent("registry-manifest.json") - ) + client = RegistryClient(manifestURL: manifestURL, session: URLSession(configuration: configuration)) } var requestCount: Int { OfflineRegistryProtocol.requestCount(for: manifestURL) } - /// A Plugins pane's fetch writes defaults and the cache file when it lands, and a write after - /// deinit recreates both directories. A fresh manifest also makes a fetch still queued a no-op. + /// Waits out a fetch the Plugins pane started on appear. func settle() async { - await client.ensureManifest(.ifStale) - } - - deinit { - try? FileManager.default.removeItem(at: directory) + await client.fetch(policy: .ifStale) } } diff --git a/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift b/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift index bd8015f793..1c436c00ee 100644 --- a/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift +++ b/TableProTests/Views/Settings/SettingsPluginsPaneRegistryTests.swift @@ -1,8 +1,3 @@ -// -// SettingsPluginsPaneRegistryTests.swift -// TableProTests -// - import AppKit @testable import TablePro import TableProTestSupport @@ -32,7 +27,7 @@ extension GlobalStateSuites { } panes.select(.plugins) - try await waitUntil { registry.client.fetchState == .loaded } + try await waitUntil { registry.client.isManifestCurrent } #expect(registry.requestCount == 1) }