diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index 7c0557da89..d2f65f94aa 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -91,13 +91,4 @@ enum PluginError: LocalizedError { if case .pluginOutdated = self { return true } return false } - - var isPermanentReconciliationFailure: Bool { - switch self { - case .noCompatibleRelease, .appTooOldForPlugin, .incompatibleVersion: - return true - default: - return false - } - } } diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index 075facbe79..4664a5d63d 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -2,116 +2,32 @@ import Foundation import os import TableProUtilities -actor PluginInstaller { - static let shared = PluginInstaller() - - static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstaller") - - private var activeTasks: [String: Task] = [:] - private var stagedUpdates: [String: URL] = [:] - - private init() {} - +protocol RegistryPluginInstalling: Sendable { func install( _ registryPlugin: RegistryPlugin, 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, - release: release, - into: userPluginsDir, - progress: progress - ) - } - } + ) async throws -> URL - func update( + func stage( _ registryPlugin: RegistryPlugin, release: RegistryRelease, + pluginId: String, + bundleName: String, into userPluginsDir: URL, - hasLiveConnections: Bool, progress: @escaping @Sendable (Double) async -> Void - ) async throws -> PluginUpdateResult { - if hasLiveConnections { - let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) { - try await self.performDownloadAndStage( - registryPlugin, - release: release, - into: userPluginsDir, - progress: progress - ) - } - stagedUpdates[registryPlugin.id] = stagedURL - return .staged(at: stagedURL) - } - - let finalURL = try await runCoalesced(pluginId: registryPlugin.id) { - try await self.performDownloadAndCommit( - registryPlugin, - release: release, - into: userPluginsDir, - progress: progress - ) - } - return .installed(pluginURL: finalURL) - } - - func commitStagedUpdate(pluginId: String, into userPluginsDir: URL) async throws -> URL { - guard let stagedURL = stagedUpdates[pluginId] else { - throw PluginError.notFound - } - guard let stagedBundle = Bundle(url: stagedURL) else { - throw PluginError.invalidBundle("Cannot create bundle from \(stagedURL.lastPathComponent)") - } - try PluginCodeSignatureVerifier.verify(bundle: stagedBundle) - let bundleName = stagedURL.deletingPathExtension().lastPathComponent - let destURL = userPluginsDir.appendingPathComponent("\(bundleName).tableplugin", isDirectory: true) - let finalURL = try Self.atomicReplace(stagedBundleURL: stagedURL, destURL: destURL) - stagedUpdates.removeValue(forKey: pluginId) - try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent()) - return finalURL - } - - func discardStagedUpdate(pluginId: String) { - guard let stagedURL = stagedUpdates.removeValue(forKey: pluginId) else { return } - try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent()) - } - - func hasStagedUpdate(pluginId: String) -> Bool { - stagedUpdates[pluginId] != nil - } - - func stagedURL(for pluginId: String) -> URL? { - stagedUpdates[pluginId] - } - - func cancelInstall(pluginId: String) { - activeTasks[pluginId]?.cancel() - } + ) async throws -> URL +} - // MARK: - Coalescing +actor PluginInstaller: RegistryPluginInstalling { + static let shared = PluginInstaller() - private func runCoalesced( - pluginId: String, - body: @Sendable @escaping () async throws -> URL - ) async throws -> URL { - if let existing = activeTasks[pluginId] { - return try await existing.value - } - let task = Task { - try await body() - } - activeTasks[pluginId] = task - defer { activeTasks.removeValue(forKey: pluginId) } - return try await task.value - } + static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstaller") - // MARK: - Download + commit (no live connections) + private init() {} - private func performDownloadAndCommit( + func install( _ registryPlugin: RegistryPlugin, release: RegistryRelease, into userPluginsDir: URL, @@ -123,20 +39,17 @@ actor PluginInstaller { into: userPluginsDir, progress: progress ) - - defer { - try? FileManager.default.removeItem(at: extracted.workingDir) - } + defer { try? FileManager.default.removeItem(at: extracted.workingDir) } let destURL = userPluginsDir.appendingPathComponent(extracted.bundleURL.lastPathComponent, isDirectory: true) return try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL) } - // MARK: - Download + stage (live connections) - - private func performDownloadAndStage( + func stage( _ registryPlugin: RegistryPlugin, release: RegistryRelease, + pluginId: String, + bundleName: String, into userPluginsDir: URL, progress: @escaping @Sendable (Double) async -> Void ) async throws -> URL { @@ -146,7 +59,14 @@ actor PluginInstaller { into: userPluginsDir, progress: progress ) - return extracted.bundleURL + defer { try? FileManager.default.removeItem(at: extracted.workingDir) } + + return try Self.stage( + bundleAt: extracted.bundleURL, + pluginId: pluginId, + bundleName: bundleName, + userPluginsDir: userPluginsDir + ) } // MARK: - Download / extract / verify / quarantine-strip @@ -232,6 +152,32 @@ actor PluginInstaller { .appendingPathComponent("PluginStaging", isDirectory: true) } + nonisolated static func stagedUpdatesRoot(for userPluginsDir: URL) -> URL { + stagingRoot(for: userPluginsDir).appendingPathComponent("Updates", isDirectory: true) + } + + nonisolated static func stagedUpdateDirectory(for pluginId: String, in userPluginsDir: URL) -> URL { + stagedUpdatesRoot(for: userPluginsDir).appendingPathComponent(pluginId, isDirectory: true) + } + + @discardableResult + nonisolated static func stage( + bundleAt bundleURL: URL, + pluginId: String, + bundleName: String, + userPluginsDir: URL + ) throws -> URL { + let fm = FileManager.default + let stagedDir = stagedUpdateDirectory(for: pluginId, in: userPluginsDir) + if fm.fileExists(atPath: stagedDir.path) { + try fm.removeItem(at: stagedDir) + } + try fm.createDirectory(at: stagedDir, withIntermediateDirectories: true) + let stagedURL = stagedDir.appendingPathComponent(bundleName, isDirectory: true) + try fm.moveItem(at: bundleURL, to: stagedURL) + return stagedURL + } + nonisolated static func extractZip(at zipURL: URL, into destDir: URL) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") @@ -317,8 +263,3 @@ private final class DownloadProgressObserver: NSObject, URLSessionTaskDelegate, continuation.finish() } } - -enum PluginUpdateResult: Sendable { - case installed(pluginURL: URL) - case staged(at: URL) -} diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index a54c2caba0..fd97147482 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -2,13 +2,6 @@ import Combine import Foundation import os -private enum ReconciliationConfig { - static let maxAttempts = 5 - static let firstRetryDelay: Duration = .seconds(30) - static let secondRetryDelay: Duration = .seconds(300) - static let thirdRetryDelay: Duration = .seconds(600) -} - enum RejectedPluginAction: Sendable { case updateAvailable(RegistryPlugin) case awaitingCompatibleBuild @@ -16,135 +9,202 @@ enum RejectedPluginAction: Sendable { case notInRegistry } +struct RegistryUpdateTarget: Equatable, Sendable { + let pluginId: String + let build: Int + let rejectedURL: URL? +} + +struct PendingRegistryUpdate: Sendable { + let target: RegistryUpdateTarget + let plugin: RegistryPlugin + let release: RegistryRelease +} + +enum RegistryUpdatePassResult: Equatable, Sendable { + case finished + case retry +} + extension PluginManager { - func scheduleReconciliation() { - reconciliationTask?.cancel() - reconciliationActive = true - reconciliationTask = Task { [weak self] in - await self?.runReconciliationLoop() + nonisolated static let registryUpdateMaxAttempts = 5 + + nonisolated static func registryUpdateRetryDelay(afterAttempt attempt: Int) -> Duration? { + switch attempt { + case ..<1, registryUpdateMaxAttempts...: nil + case 1: .seconds(30) + case 2: .seconds(300) + default: .seconds(600) } } - func runReconciliationLoop() async { - defer { reconciliationActive = false } - let outdated = rejectedPlugins.filter(\.isOutdated) - guard !outdated.isEmpty else { - emitReconciliationOutcome() - refreshRegistryUpdateSet() - return - } - - await RegistryClient.shared.fetch(policy: .mustBeCurrent) - refreshRegistryUpdateSet() - guard let manifest = RegistryClient.shared.manifest else { - reconciliationManifestAttempts += 1 - guard reconciliationManifestAttempts < ReconciliationConfig.maxAttempts else { - Self.logger.error("Reconciliation gave up: registry manifest unavailable") - applyReason(registryUnreachableReason(), to: outdated) - emitReconciliationOutcome() - return + func scheduleRegistryUpdatePass(after delay: Duration = .zero) { + guard SoftwareUpdater.isUpdatingEnabled else { return } + registryUpdateTask?.cancel() + // A connection the initial load releases runs before this task starts, and must wait for its repairs. + isRepairingRejectedPlugins = delay == .zero + registryUpdateTask = Task { [weak self] in + if delay > .zero { + try? await Task.sleep(for: delay) } - Self.logger.warning("Reconciliation deferred: registry manifest unavailable, will retry") - scheduleReconciliationRetry() - return - } - reconciliationManifestAttempts = 0 - - var sawTransientFailure = false - var retryRemaining = false - for rejected in outdated { guard !Task.isCancelled else { return } - if case .transient(let id) = await reconcile(rejected, manifest: manifest) { - sawTransientFailure = true - if reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts { - retryRemaining = true - } - } + await self?.performScheduledRegistryUpdatePass() } + } - if Self.reconciliationShouldRetry(sawTransientFailure: sawTransientFailure, retryRemaining: retryRemaining) { - scheduleReconciliationRetry() - return - } + private func performScheduledRegistryUpdatePass() async { + let result = await runRegistryUpdatePass() + guard !Task.isCancelled else { return } - emitReconciliationOutcome() + if result == .retry { + registryUpdateAttempts += 1 + if let delay = Self.registryUpdateRetryDelay(afterAttempt: registryUpdateAttempts) { + Self.logger.info("Plugin update pass will retry in \(delay.components.seconds) s") + scheduleRegistryUpdatePass(after: delay) + return + } + Self.logger.warning("Plugin update pass gave up after \(self.registryUpdateAttempts) attempts") + markUnfinishedRejectedPlugins() + } + registryUpdateAttempts = 0 + AppEvents.shared.pluginsRejected.send(rejectedPlugins) } - static func reconciliationShouldRetry(sawTransientFailure: Bool, retryRemaining: Bool) -> Bool { - sawTransientFailure && retryRemaining + func runRegistryUpdatePass( + registryClient: RegistryClient = .shared, + appBuild: Int = Bundle.main.buildNumber + ) async -> RegistryUpdatePassResult { + isRepairingRejectedPlugins = true + defer { isRepairingRejectedPlugins = false } + return await applyRegistryUpdates(to: registryUpdateTargets(appBuild: appBuild), registryClient: registryClient) } - private enum ReconcileOutcome { - case resolved - case permanent - case missing - case transient(id: String) + func waitForRejectedPluginRepairs() async { + guard isRepairingRejectedPlugins else { return } + await withCheckedContinuation { rejectedRepairWaiters.append($0) } } - private func reconcile(_ rejected: RejectedPlugin, manifest: RegistryManifest) async -> ReconcileOutcome { - guard let lookupId = resolveRegistryId(for: rejected, manifest: manifest), - let registryPlugin = manifest.plugins.first(where: { $0.id == lookupId }) else { - Self.logger.warning("Reconciliation: no registry entry for '\(rejected.name)'") - updateRejectedReason(url: rejected.url, reason: missingFromRegistryReason()) - return .missing - } + private func applyRegistryUpdates( + to targets: [RegistryUpdateTarget], + registryClient: RegistryClient + ) async -> RegistryUpdatePassResult { + guard !targets.isEmpty else { return .finished } - let attempts = reconciliationAttempts[lookupId, default: 0] - guard attempts < ReconciliationConfig.maxAttempts else { return .permanent } - reconciliationAttempts[lookupId] = attempts + 1 + await registryClient.fetch(policy: .mustBeCurrent) + guard registryClient.isManifestCurrent, let manifest = registryClient.manifest else { + Self.logger.warning("Plugin update pass deferred: the plugin manifest is unavailable") + return .retry + } - do { - let outcome = try await updateFromRegistry(registryPlugin, progress: { _ in }) - switch outcome { - case .installed: - refreshRegistryUpdateSet() - Self.logger.info("Reconciliation: auto-updated '\(rejected.name)'") - case .staged: - Self.logger.info("Reconciliation: staged '\(rejected.name)', will activate on disconnect") + let pending = Self.pendingRegistryUpdates( + installed: targets, + manifest: manifest, + channel: Self.registryChannel, + kits: Self.compatibleKits, + systemVersion: ProcessInfo.processInfo.operatingSystemVersion + ) + var result = RegistryUpdatePassResult.finished + for update in pending { + guard !Task.isCancelled else { return .finished } + guard isRegistryUpdateTarget(update.target) else { continue } + if update.target.rejectedURL == nil { + isRepairingRejectedPlugins = false } - removeFromRejected(url: rejected.url) - reconciliationAttempts.removeValue(forKey: lookupId) - 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: 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) + do { + try await applyRegistryUpdate(update) + } catch { + Self.logger.error("Updating '\(update.target.pluginId)' failed: \(error.localizedDescription)") + result = .retry } - reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts - Self.logger.error("Reconciliation: '\(rejected.name)' needs a newer app or has no compatible build") - return .permanent - } catch { - Self.logger.error("Reconciliation: transient failure for '\(rejected.name)': \(error.localizedDescription)") - if reconciliationAttempts[lookupId, default: 0] >= ReconciliationConfig.maxAttempts { - updateRejectedReason(url: rejected.url, reason: temporaryFailureReason()) + } + + let updatedIds = Set(pending.map(\.target.pluginId)) + for target in targets where !updatedIds.contains(target.pluginId) { + switch Self.rejectedAction( + registryPlugin: manifest.plugins.first { $0.id == target.pluginId }, + manifestLoaded: true, + kits: Self.compatibleKits + ) { + case .updateAvailable, .awaitingCompatibleBuild: + result = .retry + case .requiresAppUpdate: + updateRejectedReason(url: target.rejectedURL, reason: requiresAppUpdateReason()) + case .notInRegistry: + updateRejectedReason(url: target.rejectedURL, reason: missingFromRegistryReason()) } - return .transient(id: lookupId) } + return result } - private func scheduleReconciliationRetry() { - let round = max(reconciliationAttempts.values.max() ?? 0, reconciliationManifestAttempts) - let delay: Duration - switch round { - case ..<2: delay = ReconciliationConfig.firstRetryDelay - case 2: delay = ReconciliationConfig.secondRetryDelay - default: delay = ReconciliationConfig.thirdRetryDelay + // Rejected plugins come first: a connection waits on them, while a loaded plugin keeps working. + func registryUpdateTargets(appBuild: Int) -> [RegistryUpdateTarget] { + var targets: [RegistryUpdateTarget] = [] + for rejected in rejectedPlugins where rejected.isOutdated { + guard let registryId = rejected.registryId ?? rejected.bundleId, + !targets.contains(where: { $0.pluginId == registryId }) else { continue } + targets.append(RegistryUpdateTarget(pluginId: registryId, build: 0, rejectedURL: rejected.url)) } - reconciliationTask = Task { [weak self] in - try? await Task.sleep(for: delay) - guard !Task.isCancelled else { return } - await self?.runReconciliationLoop() + for entry in plugins where entry.source == .userInstalled && entry.build < appBuild { + guard !stagedPluginIds.contains(entry.id), + let registryId = Self.readRegistryMetadata(for: entry.url)?.pluginId, + !targets.contains(where: { $0.pluginId == registryId }) else { continue } + targets.append(RegistryUpdateTarget(pluginId: registryId, build: entry.build, rejectedURL: nil)) } + return targets } - private func emitReconciliationOutcome() { - AppEvents.shared.pluginsRejected.send(rejectedPlugins) + // The pass suspends on the network, and the user may uninstall or remove a plugin meanwhile. + func isRegistryUpdateTarget(_ target: RegistryUpdateTarget) -> Bool { + if let rejectedURL = target.rejectedURL { + return rejectedPlugins.contains { $0.url == rejectedURL } + } + guard !stagedPluginIds.contains(target.pluginId), + let entry = plugins.first(where: { $0.id == target.pluginId }) else { return false } + return entry.source == .userInstalled + && entry.build == target.build + && Self.readRegistryMetadata(for: entry.url)?.pluginId == target.pluginId + } + + nonisolated static func pendingRegistryUpdates( + installed: [RegistryUpdateTarget], + manifest: RegistryManifest, + channel: UpdateChannel, + kits: ClosedRange, + systemVersion: OperatingSystemVersion + ) -> [PendingRegistryUpdate] { + installed.compactMap { target in + guard let plugin = manifest.plugins.first(where: { $0.id == target.pluginId }), + let release = try? plugin.newestRelease(channel: channel, kits: kits, systemVersion: systemVersion), + release.build > target.build else { return nil } + return PendingRegistryUpdate(target: target, plugin: plugin, release: release) + } + } + + private func applyRegistryUpdate(_ update: PendingRegistryUpdate) async throws { + switch try await updateFromRegistry(update.plugin, release: update.release, progress: { _ in }) { + case .installed: + Self.logger.info("Updated '\(update.target.pluginId)' to build \(update.release.build)") + case .staged: + Self.logger.info("Staged build \(update.release.build) of '\(update.target.pluginId)' for the next launch") + } } - private func updateRejectedReason(url: URL, reason: String) { - guard let index = rejectedPlugins.firstIndex(where: { $0.url == url }) else { return } + private func markUnfinishedRejectedPlugins() { + for rejected in rejectedPlugins where rejected.isOutdated { + let reason: String + switch rejectedAction(for: rejected) { + case .requiresAppUpdate: reason = requiresAppUpdateReason() + case .notInRegistry: reason = missingFromRegistryReason() + case .awaitingCompatibleBuild where RegistryClient.shared.isManifestCurrent: reason = awaitingBuildReason() + case .awaitingCompatibleBuild: reason = registryUnreachableReason() + case .updateAvailable: reason = temporaryFailureReason() + } + updateRejectedReason(url: rejected.url, reason: reason) + } + } + + private func updateRejectedReason(url: URL?, reason: String) { + guard let url, let index = rejectedPlugins.firstIndex(where: { $0.url == url }) else { return } let existing = rejectedPlugins[index] rejectedPlugins[index] = RejectedPlugin( url: existing.url, @@ -157,11 +217,12 @@ extension PluginManager { ) } - 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.") + private func requiresAppUpdateReason() -> String { + String(localized: "A newer version of TablePro is required for this plugin. Update TablePro to keep using it.") + } + + private func awaitingBuildReason() -> String { + String(localized: "No compatible build is available yet. This plugin will update automatically once one is published.") } private func missingFromRegistryReason() -> String { @@ -176,9 +237,30 @@ extension PluginManager { String(localized: "Updating this plugin didn't finish. TablePro will try again the next time it launches.") } - private func applyReason(_ reason: String, to plugins: [RejectedPlugin]) { - for plugin in plugins { - updateRejectedReason(url: plugin.url, reason: reason) + // Only plist checks here: the signature was checked when staged and is checked again before any code loads. + nonisolated static func applyStagedUpdates(in userPluginsDir: URL) { + let fm = FileManager.default + guard let stagedDirs = try? fm.contentsOfDirectory( + at: PluginInstaller.stagedUpdatesRoot(for: userPluginsDir), + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return } + + for stagedDir in stagedDirs { + defer { try? fm.removeItem(at: stagedDir) } + do { + let bundleURL = try PluginInstaller.findBundle(in: stagedDir) + guard let bundle = Bundle(url: bundleURL), + bundle.bundleIdentifier == stagedDir.lastPathComponent else { + throw PluginError.invalidBundle("The staged bundle is not \(stagedDir.lastPathComponent)") + } + try validateBundleVersions(bundle) + let destURL = userPluginsDir.appendingPathComponent(bundleURL.lastPathComponent, isDirectory: true) + _ = try PluginInstaller.atomicReplace(stagedBundleURL: bundleURL, destURL: destURL) + logger.info("Applied the staged update of '\(stagedDir.lastPathComponent)'") + } catch { + logger.error("Dropped the staged update of '\(stagedDir.lastPathComponent)': \(error.localizedDescription)") + } } } @@ -195,26 +277,6 @@ extension PluginManager { rejectedPlugins.removeAll { $0.url == url } } - 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 }), - let release = try? Self.newestRelease(of: registryPlugin), - release.build > installed.build else { return nil } - return (registryPlugin, release) - } - - func refreshRegistryUpdateSet() { - var available: Set = [] - for plugin in plugins where registryUpdate(for: plugin.id) != nil { - available.insert(plugin.id) - } - if available != pluginsWithRegistryUpdate { - pluginsWithRegistryUpdate = available - } - } - func registryPlugin(for rejected: RejectedPlugin) -> RegistryPlugin? { guard let manifest = RegistryClient.shared.manifest else { return nil } guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return nil } @@ -284,35 +346,22 @@ extension PluginManager { } func ensurePluginReady(forTypeId typeId: String) async { - if reconciliationActive, let task = reconciliationTask { - await task.value - } - guard hasOutdatedRejectedPlugin(forTypeId: typeId) else { return } - await reconcileOutdated(matchingTypeId: typeId) - } - - private func reconcileOutdated(matchingTypeId typeId: String) async { - let targets = rejectedPlugins.filter { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } + guard SoftwareUpdater.isUpdatingEnabled, hasOutdatedRejectedPlugin(forTypeId: typeId) else { return } + await waitForRejectedPluginRepairs() + let targets = registryUpdateTargets(forTypeId: typeId, appBuild: Bundle.main.buildNumber) guard !targets.isEmpty else { return } - reconciliationActive = true - defer { reconciliationActive = false } - 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) { - reconciliationAttempts.removeValue(forKey: lookupId) - } - _ = await reconcile(target, manifest: manifest) - } - refreshRegistryUpdateSet() - emitReconciliationOutcome() + _ = await applyRegistryUpdates(to: targets, registryClient: .shared) + AppEvents.shared.pluginsRejected.send(rejectedPlugins) } - func retriggerReconciliation() { - guard !reconciliationActive else { return } - guard rejectedPlugins.contains(where: \.isOutdated) else { return } - reconciliationAttempts.removeAll() - reconciliationManifestAttempts = 0 - scheduleReconciliation() + func registryUpdateTargets(forTypeId typeId: String, appBuild: Int) -> [RegistryUpdateTarget] { + let rejectedURLs = Set( + rejectedPlugins + .filter { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } + .map(\.url) + ) + return registryUpdateTargets(appBuild: appBuild).filter { target in + target.rejectedURL.map { rejectedURLs.contains($0) } ?? false + } } } diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index 48acda58ac..ad33fe37e0 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -1,25 +1,26 @@ import Foundation import os +enum RegistryUpdateMode: Equatable, Sendable { + case installNow + case stageForNextLaunch +} + extension PluginManager { func installFromRegistry( _ registryPlugin: RegistryPlugin, + release: RegistryRelease? = nil, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginEntry { if plugins.contains(where: { $0.id == registryPlugin.id }) { throw PluginError.pluginConflict(existingName: registryPlugin.name) } - guard !installsInFlight.contains(registryPlugin.id) else { - throw PluginError.installFailed( - String(localized: "Another install is already in progress for this plugin") - ) - } - installsInFlight.insert(registryPlugin.id) + try reserveInstall(of: registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } - let release = try Self.newestRelease(of: registryPlugin) + let release = try release ?? Self.newestRelease(of: registryPlugin) - let finalURL = try await PluginInstaller.shared.install( + let finalURL = try await registryInstaller.install( registryPlugin, release: release, into: userPluginsDir, @@ -27,60 +28,70 @@ extension PluginManager { ) saveRegistryMetadata(pluginId: registryPlugin.id, pluginURL: finalURL) - let entry = try await loadPluginAsync(at: finalURL, source: .userInstalled) - refreshRegistryUpdateSet() - return entry + return try await loadPluginAsync(at: finalURL, source: .userInstalled) + } + + // A loaded bundle cannot be unloaded and Bundle(url:) caches per path, so it updates at the next launch. + func registryUpdateMode(forPluginId pluginId: String) -> RegistryUpdateMode { + plugins.contains { $0.id == pluginId } ? .stageForNextLaunch : .installNow } func updateFromRegistry( _ registryPlugin: RegistryPlugin, + release: RegistryRelease? = nil, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginUpdateOutcome { - if let existing = plugins.first(where: { $0.id == registryPlugin.id }), - existing.source == .builtIn { - throw PluginError.pluginConflict(existingName: existing.name) + switch registryUpdateMode(forPluginId: registryPlugin.id) { + case .installNow: + let entry = try await installFromRegistry(registryPlugin, release: release, progress: progress) + rejectedPlugins.removeAll { ($0.registryId ?? $0.bundleId) == registryPlugin.id } + return .installed(entry) + case .stageForNextLaunch: + try await stageFromRegistry(registryPlugin, release: release, progress: progress) + return .staged(pluginId: registryPlugin.id) } + } - guard !installsInFlight.contains(registryPlugin.id) else { - throw PluginError.installFailed( - String(localized: "Another install is already in progress for this plugin") - ) + private func stageFromRegistry( + _ registryPlugin: RegistryPlugin, + release: RegistryRelease?, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws { + guard let existing = plugins.first(where: { $0.id == registryPlugin.id }) else { + throw PluginError.notFound } - installsInFlight.insert(registryPlugin.id) - defer { installsInFlight.remove(registryPlugin.id) } + guard existing.source == .userInstalled else { + throw PluginError.pluginConflict(existingName: existing.name) + } + try reserveInstall(of: existing.id) + defer { installsInFlight.remove(existing.id) } - let release = try Self.newestRelease(of: registryPlugin) + let release = try release ?? Self.newestRelease(of: registryPlugin) - let result = try await PluginInstaller.shared.update( + _ = try await registryInstaller.stage( registryPlugin, release: release, + pluginId: existing.id, + bundleName: existing.url.lastPathComponent, into: userPluginsDir, - hasLiveConnections: pluginHasLiveConnections(registryPlugin), progress: { fraction in await progress(fraction) } ) + guard plugins.contains(where: { $0.id == existing.id }) else { + removeStagedUpdate(pluginId: existing.id) + throw PluginError.notFound + } + stagedPluginIds.insert(existing.id) + PluginInstallTracker.shared.completeInstall(pluginId: existing.id) + } - switch result { - case .installed(let pluginURL): - saveRegistryMetadata(pluginId: registryPlugin.id, pluginURL: pluginURL) - let entry = try await loadPluginAsync( - at: pluginURL, - source: .userInstalled, - replacingBundleId: registryPlugin.id - ) - stagedUpdates.removeValue(forKey: registryPlugin.id) - refreshRegistryUpdateSet() - return .installed(entry) - case .staged(let stagedURL): - stagedUpdates[registryPlugin.id] = StagedPluginUpdate( - registryPlugin: registryPlugin, - stagedURL: stagedURL - ) - PluginInstallTracker.shared.markStaged( - pluginId: registryPlugin.id, - newVersion: release.version + // One install per plugin at a time: a file and a registry download would overwrite each other's copy. + private func reserveInstall(of pluginId: String) throws { + guard !installsInFlight.contains(pluginId) else { + throw PluginError.installFailed( + String(localized: "Another install is already in progress for this plugin") ) - return .staged(pluginId: registryPlugin.id) } + installsInFlight.insert(pluginId) } func installPlugin(from url: URL) async throws -> PluginEntry { @@ -90,44 +101,16 @@ extension PluginManager { return try await installLocalZip(from: url) } - func commitStagedUpdate(pluginId: String) async throws -> PluginEntry { - guard let pending = stagedUpdates[pluginId] else { - throw PluginError.notFound - } - guard !pluginHasLiveConnections(pending.registryPlugin) else { - throw PluginError.installFailed( - String(localized: "Plugin has active connections") - ) - } - guard !installsInFlight.contains(pluginId) else { - throw PluginError.installFailed( - String(localized: "Another install is already in progress for this plugin") - ) - } - installsInFlight.insert(pluginId) - defer { - installsInFlight.remove(pluginId) - stagedUpdates.removeValue(forKey: pluginId) + func removeStagedUpdate(pluginId: String) { + let stagedDir = PluginInstaller.stagedUpdateDirectory(for: pluginId, in: userPluginsDir) + do { + try FileManager.default.removeItem(at: stagedDir) + } catch CocoaError.fileNoSuchFile { + // Nothing was staged. + } catch { + Self.logger.error("Failed to remove the staged update of '\(pluginId)': \(error.localizedDescription)") } - let finalURL = try await PluginInstaller.shared.commitStagedUpdate( - pluginId: pluginId, - into: userPluginsDir - ) - saveRegistryMetadata(pluginId: pluginId, pluginURL: finalURL) - let entry = try await loadPluginAsync( - at: finalURL, - source: .userInstalled, - replacingBundleId: pluginId - ) - PluginInstallTracker.shared.completeInstall(pluginId: pluginId) - refreshRegistryUpdateSet() - return entry - } - - func discardStagedUpdate(pluginId: String) async { - await PluginInstaller.shared.discardStagedUpdate(pluginId: pluginId) - stagedUpdates.removeValue(forKey: pluginId) - PluginInstallTracker.shared.clearInstall(pluginId: pluginId) + stagedPluginIds.remove(pluginId) } nonisolated static func newestRelease( @@ -141,35 +124,27 @@ extension PluginManager { ) } - func pluginHasLiveConnections(_ registryPlugin: RegistryPlugin) -> Bool { - let typeIds = Set(registryPlugin.databaseTypeIds ?? [registryPlugin.id]) - return DatabaseManager.shared.activeSessions.values.contains { session in - typeIds.contains(session.connection.type.pluginTypeId) - } - } - - func reattemptStagedUpdates() { - for (pluginId, pending) in stagedUpdates where !pluginHasLiveConnections(pending.registryPlugin) { - guard !installsInFlight.contains(pluginId) else { continue } - Task { [weak self] in - _ = try? await self?.commitStagedUpdate(pluginId: pluginId) - } - } - } - // MARK: - Local bundle / zip install + // A file install drops the registry sidecar, so the update pass never replaces the file's bundle. private func installLooseBundle(from url: URL) async throws -> PluginEntry { guard let sourceBundle = Bundle(url: url) else { throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") } try PluginCodeSignatureVerifier.verify(bundle: sourceBundle) + try Self.validateBundleVersions(sourceBundle) let bundleId = sourceBundle.bundleIdentifier ?? url.lastPathComponent + try reserveInstall(of: bundleId) + defer { installsInFlight.remove(bundleId) } + + if let existing = plugins.first(where: { $0.id == bundleId }) { + guard url.standardizedFileURL != existing.url.standardizedFileURL else { return existing } + PluginInstaller.stripQuarantine(at: url) + return try stageForNextLaunch(bundleAt: url, replacing: existing) + } try FileManager.default.createDirectory(at: userPluginsDir, withIntermediateDirectories: true) let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent) - let replaceId: String? = plugins.contains(where: { $0.id == bundleId }) ? bundleId : nil - let loadURL: URL if url.standardizedFileURL != destURL.standardizedFileURL { loadURL = try PluginInstaller.atomicReplace(stagedBundleURL: url, destURL: destURL) @@ -177,7 +152,8 @@ extension PluginManager { loadURL = destURL } PluginInstaller.stripQuarantine(at: loadURL) - return try await loadPluginAsync(at: loadURL, source: .userInstalled, replacingBundleId: replaceId) + removeRegistryMetadata(for: loadURL) + return try await loadPluginAsync(at: loadURL, source: .userInstalled) } private func installLocalZip(from url: URL) async throws -> PluginEntry { @@ -197,12 +173,30 @@ extension PluginManager { PluginInstaller.stripQuarantine(at: bundleURL) let bundleId = bundle.bundleIdentifier ?? bundleURL.lastPathComponent - let replaceId: String? = plugins.contains(where: { $0.id == bundleId }) ? bundleId : nil + try reserveInstall(of: bundleId) + defer { installsInFlight.remove(bundleId) } + + if let existing = plugins.first(where: { $0.id == bundleId }) { + return try stageForNextLaunch(bundleAt: bundleURL, replacing: existing) + } try FileManager.default.createDirectory(at: userPluginsDir, withIntermediateDirectories: true) let destURL = userPluginsDir.appendingPathComponent(bundleURL.lastPathComponent) let finalURL = try PluginInstaller.atomicReplace(stagedBundleURL: bundleURL, destURL: destURL) - return try await loadPluginAsync(at: finalURL, source: .userInstalled, replacingBundleId: replaceId) + removeRegistryMetadata(for: finalURL) + return try await loadPluginAsync(at: finalURL, source: .userInstalled) + } + + func stageForNextLaunch(bundleAt bundleURL: URL, replacing existing: PluginEntry) throws -> PluginEntry { + try PluginInstaller.stage( + bundleAt: bundleURL, + pluginId: existing.id, + bundleName: existing.url.lastPathComponent, + userPluginsDir: userPluginsDir + ) + removeRegistryMetadata(for: existing.url) + stagedPluginIds.insert(existing.id) + return existing } } @@ -210,8 +204,3 @@ enum PluginUpdateOutcome: Sendable { case installed(PluginEntry) case staged(pluginId: String) } - -struct StagedPluginUpdate: Sendable { - let registryPlugin: RegistryPlugin - let stagedURL: URL -} diff --git a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift index f257289bf1..12bfefacba 100644 --- a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift +++ b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift @@ -1,8 +1,3 @@ -// -// PluginManager+Lifecycle.swift -// TablePro -// - import Foundation import os import Security @@ -33,13 +28,9 @@ extension PluginManager { } PluginSettingsStorage(pluginId: id).removeAll() - - if stagedUpdates[id] != nil { - await discardStagedUpdate(pluginId: id) - } + removeStagedUpdate(pluginId: id) queryBuildingDriverCache.removeAll() - refreshRegistryUpdateSet() Self.logger.info("Uninstalled plugin '\(id)'") needsRestart = true diff --git a/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift b/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift deleted file mode 100644 index 244578bf65..0000000000 --- a/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PluginManager+NetworkMonitor.swift -// TablePro -// - -import Network - -extension PluginManager { - func startNetworkReachabilityMonitor() { - guard pluginNetworkMonitor == nil else { return } - let monitor = NWPathMonitor() - pluginNetworkMonitor = monitor - monitor.pathUpdateHandler = { [weak self] path in - let satisfied = path.status == .satisfied - Task { @MainActor [weak self] in - self?.handleNetworkPathChange(satisfied: satisfied) - } - } - monitor.start(queue: DispatchQueue(label: "com.TablePro.pluginNetworkMonitor")) - } - - private func handleNetworkPathChange(satisfied: Bool) { - defer { lastNetworkSatisfied = satisfied } - guard satisfied, !lastNetworkSatisfied else { return } - retriggerReconciliation() - } -} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index cf32a5bba8..5a96b81733 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import Network import os import Security import SwiftUI @@ -8,7 +7,7 @@ import TableProPluginKit @MainActor final class PluginManager: ObservableObject { - static let shared = PluginManager(userDefaults: AppStorageEnvironment.shared.defaults) + static let shared = PluginManager() nonisolated static let currentPluginKitVersion = pluginKitVersion(infoKey: "TableProPluginKitVersion") nonisolated static let minimumCompatiblePluginKitVersion = pluginKitVersion( @@ -25,18 +24,17 @@ final class PluginManager: ObservableObject { private let builtInPluginsURL: URL? internal let userPluginsDir: URL + internal let registryInstaller: any RegistryPluginInstalling @Published internal(set) var plugins: [PluginEntry] = [] - @Published internal(set) var stagedUpdates: [String: StagedPluginUpdate] = [:] - - @Published internal(set) var pluginsWithRegistryUpdate: Set = [] + @Published internal(set) var stagedPluginIds: Set = [] var isInstalling: Bool { PluginInstallTracker.shared.activeInstalls.values.contains { progress in switch progress.phase { case .downloading, .installing: true - case .stagedPendingActivation, .completed, .failed: false + case .completed, .failed: false } } } @@ -110,27 +108,35 @@ final class PluginManager: ObservableObject { private var lazyImportURLs: [String: URL] = [:] private var activatedBundleIds: Set = [] - internal var reconciliationTask: Task? - internal var reconciliationActive = false - internal var reconciliationAttempts: [String: Int] = [:] - internal var reconciliationManifestAttempts = 0 - private var connectionStatusSubscription: AnyCancellable? - internal var pluginNetworkMonitor: NWPathMonitor? - internal var lastNetworkSatisfied = false + internal var registryUpdateTask: Task? + internal var registryUpdateAttempts = 0 internal var installsInFlight: Set = [] + // Set from a pass's start until its rejected plugins are done, so a connection never waits on a staging download. + internal var isRepairingRejectedPlugins = false { + didSet { + guard !isRepairingRejectedPlugins else { return } + let waiters = rejectedRepairWaiters + rejectedRepairWaiters.removeAll() + waiters.forEach { $0.resume() } + } + } + + internal var rejectedRepairWaiters: [CheckedContinuation] = [] + internal var pendingSignatureChecks: [URL] = [] @Published var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] init( - userDefaults: UserDefaults = AppStorageEnvironment.shared.defaults, builtInPluginsURL: URL? = Bundle.main.builtInPlugInsURL, userPluginsDir: URL = PluginManager.defaultUserPluginsDir(), + registryInstaller: any RegistryPluginInstalling = PluginInstaller.shared, initialLoadClock: any Clock = ContinuousClock() ) { self.builtInPluginsURL = builtInPluginsURL self.userPluginsDir = userPluginsDir + self.registryInstaller = registryInstaller self.initialLoadClock = initialLoadClock } @@ -182,6 +188,7 @@ final class PluginManager: ObservableObject { func loadPlugins() { hasStartedInitialLoad = true + Self.applyStagedUpdates(in: userPluginsDir) cleanStaleStagingArtifacts() discoverAllPlugins() @@ -215,16 +222,13 @@ final class PluginManager: ObservableObject { let eagerCount = validated.count Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(lazyCount) lazy + \(eagerCount) eager (\(self.driverPlugins.count) driver(s) active, \(self.exportPlugins.count) export(s) active, \(self.importPlugins.count) import(s) active)") - self.refreshRegistryUpdateSet() - self.subscribeToConnectionStatusChanges() - guard SoftwareUpdater.isUpdatingEnabled else { return } - self.startNetworkReachabilityMonitor() - self.scheduleReconciliation() + self.scheduleRegistryUpdatePass() } } private func cleanStaleStagingArtifacts() { let stagingRoot = PluginInstaller.stagingRoot(for: userPluginsDir) + let stagedUpdatesRoot = PluginInstaller.stagedUpdatesRoot(for: userPluginsDir) let pluginsDir = userPluginsDir Task.detached(priority: .utility) { let fm = FileManager.default @@ -233,7 +237,7 @@ final class PluginManager: ObservableObject { includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] ) { - for item in stagingContents { + for item in stagingContents where item.lastPathComponent != stagedUpdatesRoot.lastPathComponent { try? fm.removeItem(at: item) } } @@ -249,18 +253,6 @@ final class PluginManager: ObservableObject { } } - private func subscribeToConnectionStatusChanges() { - guard connectionStatusSubscription == nil else { return } - connectionStatusSubscription = AppEvents.shared.connectionStatusChanged - .receive(on: RunLoop.main) - .sink { [weak self] change in - guard let self else { return } - if case .disconnected = change.status { - self.reattemptStagedUpdates() - } - } - } - // MARK: - Lazy Plugin Activation private func registerLazyManifest(at url: URL, source: PluginSource, manifest: PluginManifest) { @@ -466,8 +458,9 @@ final class PluginManager: ObservableObject { compatibleKits.contains(kit) } + // Bundle caches Info.plist per path, so a rejected plugin repaired in place still reports its old kit. nonisolated internal static func validateBundleVersions(_ bundle: Bundle) throws { - guard let version = bundle.infoDictionary?["TableProPluginKitVersion"] as? Int else { + guard let version = infoPlist(at: bundle.bundleURL)["TableProPluginKitVersion"] as? Int else { throw PluginError.pluginOutdated( pluginVersion: 0, requiredVersion: currentPluginKitVersion @@ -505,24 +498,18 @@ final class PluginManager: ObservableObject { return bundle } - nonisolated static func bundleShortVersion(at url: URL) -> String? { + nonisolated static func infoPlist(at url: URL) -> [String: Any] { let infoPlistURL = url.appendingPathComponent("Contents/Info.plist") guard let data = try? Data(contentsOf: infoPlistURL), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), let dictionary = plist as? [String: Any] else { - return nil + return [:] } - return dictionary["CFBundleShortVersionString"] as? String + return dictionary } nonisolated static func bundleBuild(at url: URL) -> Int { - let infoPlistURL = url.appendingPathComponent("Contents/Info.plist") - guard let data = try? Data(contentsOf: infoPlistURL), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let dictionary = plist as? [String: Any] else { - return 0 - } - return (dictionary["CFBundleVersion"] as? String).flatMap { Int($0) } ?? 0 + (infoPlist(at: url)["CFBundleVersion"] as? String).flatMap { Int($0) } ?? 0 } private struct EagerLoadFailure { @@ -586,7 +573,7 @@ final class PluginManager: ObservableObject { let importType = principalClass as? any ImportFormatPlugin.Type let version: String - if let declared = Self.bundleShortVersion(at: url) { + if let declared = Self.infoPlist(at: url)["CFBundleShortVersionString"] as? String { version = declared } else { Self.logger.warning("Plugin '\(bundleId)' missing CFBundleShortVersionString; defaulting to 0.0.0") @@ -770,17 +757,9 @@ final class PluginManager: ObservableObject { } @discardableResult - func loadPluginAsync( - at url: URL, - source: PluginSource, - replacingBundleId: String? = nil - ) async throws -> PluginEntry { + func loadPluginAsync(at url: URL, source: PluginSource) async throws -> PluginEntry { let loaded = try await Self.validateAndLoadBundleAsync(at: url, source: source) - if let replacingBundleId { - replaceExistingPlugin(bundleId: replacingBundleId) - } - guard let entry = registerBundle(loaded, url: url, source: source) else { throw PluginError.invalidBundle("Principal class does not conform to TableProPlugin") } @@ -815,13 +794,6 @@ final class PluginManager: ObservableObject { return provider.browseFilterDescriptor } - func replaceExistingPlugin(bundleId: String) { - guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return } - unregisterCapabilities(pluginId: bundleId) - plugins[existingIndex].bundle.unload() - plugins.remove(at: existingIndex) - } - func unregisterCapabilities(pluginId: String) { pluginInstances.removeValue(forKey: pluginId) diff --git a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift index 88be5ecb8e..8d716148b3 100644 --- a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift +++ b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift @@ -1,8 +1,3 @@ -// -// PluginInstallTracker.swift -// TablePro -// - import Combine import Foundation @@ -46,18 +41,6 @@ final class PluginInstallTracker: ObservableObject { } } - func markStaged(pluginId: String, newVersion: String) { - if activeInstalls[pluginId] == nil { - activeInstalls[pluginId] = InstallProgress(phase: .stagedPendingActivation(newVersion: newVersion)) - } else { - activeInstalls[pluginId]?.phase = .stagedPendingActivation(newVersion: newVersion) - } - } - - func clearInstall(pluginId: String) { - activeInstalls.removeValue(forKey: pluginId) - } - /// A connection knows its `DatabaseType`, not the registry plugin id, so without this the /// form has no way to reach the progress the installer is already publishing. func state(forDatabaseType type: DatabaseType) -> InstallProgress? { @@ -68,10 +51,6 @@ final class PluginInstallTracker: ObservableObject { ) else { return nil } return activeInstalls[plugin.id] } - - func state(for pluginId: String) -> InstallProgress? { - activeInstalls[pluginId] - } } struct InstallProgress: Equatable { @@ -80,7 +59,6 @@ struct InstallProgress: Equatable { enum Phase: Equatable { case downloading(fraction: Double) case installing - case stagedPendingActivation(newVersion: String) case completed case failed(String) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 90dab53954..34af60495b 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -11794,6 +11794,7 @@ } }, "%lld%%" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17812,6 +17813,7 @@ } }, "Activate next time you launch TablePro" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17852,6 +17854,7 @@ } }, "Activate Now" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -17892,6 +17895,7 @@ } }, "Activation Failed" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -39936,6 +39940,7 @@ } }, "Close active connections to apply." : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -122888,6 +122893,7 @@ } }, "On Quit" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -131567,6 +131573,7 @@ } }, "Plugin has active connections" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -140371,6 +140378,7 @@ } }, "Ready after restart" : { + "extractionState" : "stale", "comment" : "A description of a plugin that is ready to use after the app is restarted.", "isCommentAutoGenerated" : true, "localizations" : { @@ -147998,6 +148006,7 @@ } }, "Retry Update" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -203333,6 +203342,7 @@ } }, "Updated to v%@" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -203371,6 +203381,9 @@ } } } + }, + "Updates when TablePro restarts" : { + }, "Updating Cells…" : { "localizations" : { @@ -203423,6 +203436,7 @@ } }, "Updating…" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -205252,6 +205266,7 @@ } }, "v%@ available" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -205292,6 +205307,7 @@ } }, "v%@ ready to activate" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { diff --git a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift index 4804e3559c..dc269e0bbe 100644 --- a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift +++ b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift @@ -47,9 +47,6 @@ struct PluginInstallStatusRow: View { labelledSpinner(String(localized: "Downloading…")) case .installing: labelledSpinner(String(localized: "Installing…")) - case .stagedPendingActivation: - Text("Ready after restart") - .foregroundStyle(.secondary) case .completed: Text("Installed") .foregroundStyle(.secondary) diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index f64a83a6e2..c62d2446b8 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers struct InstalledPluginsView: View { @ObservedObject private var pluginManager = PluginManager.shared @ObservedObject private var registryClient: RegistryClient + // isInstalling reads the tracker, which the plugin manager does not republish. @ObservedObject private var installTracker = PluginInstallTracker.shared @ObservedObject private var navigation = PluginsSettingsNavigation.shared @@ -336,16 +337,16 @@ struct InstalledPluginsView: View { } .font(.caption) .foregroundStyle(.secondary) + + if pluginManager.stagedPluginIds.contains(plugin.id) { + Text("Updates when TablePro restarts") + .font(.caption) + .foregroundStyle(.secondary) + } } Spacer() - if pluginManager.registryUpdate(for: plugin.id) != nil { - Image(systemName: "arrow.up.circle.fill") - .foregroundStyle(.blue) - .font(.caption) - } - Text(plugin.source == .builtIn ? String(localized: "Built-in") : String(localized: "User")) .font(.caption2) .foregroundStyle(.secondary) @@ -372,10 +373,6 @@ struct InstalledPluginsView: View { .font(.subheadline) .foregroundStyle(.secondary) - if let update = pluginManager.registryUpdate(for: selected.id) { - updateActionView(for: selected, registryPlugin: update.plugin, version: update.release.version) - } - if !selected.pluginDescription.isEmpty { Text(selected.pluginDescription) .font(.callout) @@ -457,76 +454,6 @@ struct InstalledPluginsView: View { } } - // MARK: - Update - - @ViewBuilder - 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): - HStack(spacing: 8) { - ProgressView(value: fraction) - Text("\(Int(fraction * 100))%") - .font(.callout) - .foregroundStyle(.secondary) - .monospacedDigit() - } - case .installing: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Updating…") - .font(.callout) - .foregroundStyle(.secondary) - } - case .stagedPendingActivation(let newVersion): - HStack(spacing: 8) { - Image(systemName: "clock.arrow.circlepath") - .foregroundStyle(.orange) - VStack(alignment: .leading, spacing: 2) { - Text(String(format: String(localized: "v%@ ready to activate"), newVersion)) - .font(.callout) - Text(String(localized: "Close active connections to apply.")) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button(String(localized: "Activate Now")) { - activateStagedPlugin(plugin) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - Button(String(localized: "On Quit")) { - installTracker.clearInstall(pluginId: plugin.id) - } - .buttonStyle(.bordered) - .controlSize(.small) - .help(String(localized: "Activate next time you launch TablePro")) - } - case .completed: - Label( - String(format: String(localized: "Updated to v%@"), version), - systemImage: "checkmark.circle.fill" - ) - .foregroundStyle(.green) - .font(.callout) - case .failed: - Button(String(localized: "Retry Update")) { updatePlugin(registryPlugin) } - .buttonStyle(.bordered) - .controlSize(.small) - } - } else { - HStack(spacing: 8) { - Text(String(format: String(localized: "v%@ available"), version)) - .font(.callout) - .foregroundStyle(.blue) - Button(String(localized: "Update")) { updatePlugin(registryPlugin) } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - } - private func updatePlugin(_ registryPlugin: RegistryPlugin) { Task { let result = await pluginManager.performRegistryUpdate(registryPlugin) @@ -538,18 +465,6 @@ struct InstalledPluginsView: View { } } - private func activateStagedPlugin(_ plugin: PluginEntry) { - Task { - do { - _ = try await pluginManager.commitStagedUpdate(pluginId: plugin.id) - } catch { - errorAlertTitle = String(localized: "Activation Failed") - errorAlertMessage = error.localizedDescription - showErrorAlert = true - } - } - } - // MARK: - Actions private func revealRequestedPlugin() { diff --git a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift index a52ccda553..f896bdb93f 100644 --- a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift +++ b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift @@ -37,18 +37,11 @@ private final class UnreachableRegistryProtocol: URLProtocol, @unchecked Sendabl @Suite("Driver unavailability", .serialized) @MainActor struct DriverUnavailabilityTests { - private let managerSuite: EphemeralDefaults - - init() throws { - managerSuite = try EphemeralDefaults() - } - private func makeManager() throws -> (manager: PluginManager, root: URL) { let root = FileManager.default.temporaryDirectory .appendingPathComponent("DriverUnavailability-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) let manager = PluginManager( - userDefaults: managerSuite.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) diff --git a/TableProTests/Core/Plugins/PluginBundleVersionTests.swift b/TableProTests/Core/Plugins/PluginBundleVersionTests.swift index 00957fac6a..971f8d838b 100644 --- a/TableProTests/Core/Plugins/PluginBundleVersionTests.swift +++ b/TableProTests/Core/Plugins/PluginBundleVersionTests.swift @@ -21,40 +21,6 @@ struct PluginBundleVersionTests { return bundle } - @Test("bundleShortVersion reads CFBundleShortVersionString from disk") - func readsVersionFromDisk() throws { - let dir = try makeTempDir() - defer { try? FileManager.default.removeItem(at: dir) } - - let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleShortVersionString": "1.2.3"]) - #expect(PluginManager.bundleShortVersion(at: bundle) == "1.2.3") - } - - @Test("bundleShortVersion returns nil when the key is missing") - func returnsNilWhenMissing() throws { - let dir = try makeTempDir() - defer { try? FileManager.default.removeItem(at: dir) } - - let bundle = try writeBundle(at: dir, name: "Driver", info: [:]) - #expect(PluginManager.bundleShortVersion(at: bundle) == nil) - } - - @Test("bundleShortVersion sees the new version after the bundle is replaced in place") - func seesNewVersionAfterInPlaceReplace() throws { - let dir = try makeTempDir() - defer { try? FileManager.default.removeItem(at: dir) } - - let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleShortVersionString": "1.0.0"]) - - let cached = Bundle(url: bundle) - #expect(cached?.infoDictionary?["CFBundleShortVersionString"] as? String == "1.0.0") - - _ = try writeBundle(at: dir, name: "Driver", info: ["CFBundleShortVersionString": "2.0.0"]) - - #expect(PluginManager.bundleShortVersion(at: bundle) == "2.0.0") - #expect(Bundle(url: bundle)?.infoDictionary?["CFBundleShortVersionString"] as? String == "1.0.0") - } - @Test("bundleBuild reads CFBundleVersion from disk") func readsBuildFromDisk() throws { let dir = try makeTempDir() @@ -89,17 +55,4 @@ struct PluginBundleVersionTests { #expect(PluginManager.bundleBuild(at: dir.appendingPathComponent("Missing.tableplugin")) == 0) } - - @Test("bundleBuild sees the new build after the bundle is replaced in place") - func seesNewBuildAfterInPlaceReplace() throws { - let dir = try makeTempDir() - defer { try? FileManager.default.removeItem(at: dir) } - - let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleVersion": "820001"]) - #expect(Bundle(url: bundle)?.infoDictionary?["CFBundleVersion"] as? String == "820001") - - _ = try writeBundle(at: dir, name: "Driver", info: ["CFBundleVersion": "820099"]) - - #expect(PluginManager.bundleBuild(at: bundle) == 820_099) - } } diff --git a/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift b/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift deleted file mode 100644 index 2cfae67948..0000000000 --- a/TableProTests/Core/Plugins/PluginInstallTrackerStagedTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// PluginInstallTrackerStagedTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@MainActor -struct PluginInstallTrackerStagedTests { - - private let pluginId = "com.example.staged.test" - - private func cleanup() { - PluginInstallTracker.shared.clearInstall(pluginId: pluginId) - } - - @Test("markStaged transitions phase to stagedPendingActivation") - func markStagedSetsPhase() { - defer { cleanup() } - let tracker = PluginInstallTracker.shared - tracker.beginInstall(pluginId: pluginId) - tracker.markStaged(pluginId: pluginId, newVersion: "1.0.23") - let phase = tracker.state(for: pluginId)?.phase - if case .stagedPendingActivation(let version) = phase { - #expect(version == "1.0.23") - } else { - Issue.record("Expected stagedPendingActivation, got \(String(describing: phase))") - } - } - - @Test("markStaged creates entry even when no prior beginInstall") - func markStagedCreatesEntry() { - defer { cleanup() } - let tracker = PluginInstallTracker.shared - tracker.markStaged(pluginId: pluginId, newVersion: "2.0.0") - let phase = tracker.state(for: pluginId)?.phase - if case .stagedPendingActivation(let version) = phase { - #expect(version == "2.0.0") - } else { - Issue.record("Expected stagedPendingActivation, got \(String(describing: phase))") - } - } - - @Test("clearInstall removes staged entry") - func clearInstallRemovesStaged() { - let tracker = PluginInstallTracker.shared - tracker.markStaged(pluginId: pluginId, newVersion: "1.0.0") - tracker.clearInstall(pluginId: pluginId) - #expect(tracker.state(for: pluginId) == nil) - } -} diff --git a/TableProTests/Core/Plugins/PluginInstallerCoalescingTests.swift b/TableProTests/Core/Plugins/PluginInstallerCoalescingTests.swift deleted file mode 100644 index 2d2f5c87a3..0000000000 --- a/TableProTests/Core/Plugins/PluginInstallerCoalescingTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// PluginInstallerCoalescingTests.swift -// TableProTests -// - -import Foundation -import Testing -@testable import TablePro - -@Suite("PluginInstaller staged-update bookkeeping", .serialized) -struct PluginInstallerCoalescingTests { - - @Test("hasStagedUpdate returns false for unknown pluginId") - func unknownStagedUpdate() async { - let installer = PluginInstaller.shared - let unknown = await installer.hasStagedUpdate(pluginId: "com.nonexistent.plugin.\(UUID().uuidString)") - #expect(unknown == false) - } - - @Test("discardStagedUpdate is safe on unknown pluginId") - func discardUnknownIsSafe() async { - let installer = PluginInstaller.shared - await installer.discardStagedUpdate(pluginId: "com.nonexistent.plugin.\(UUID().uuidString)") - } - - @Test("stagedURL returns nil for unknown pluginId") - func unknownStagedURL() async { - let installer = PluginInstaller.shared - let url = await installer.stagedURL(for: "com.nonexistent.plugin.\(UUID().uuidString)") - #expect(url == nil) - } -} diff --git a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift index ddca0cf5be..e5941a6e0f 100644 --- a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift @@ -1,8 +1,3 @@ -// -// PluginLazyActivationVerificationTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProPluginKit @@ -32,10 +27,7 @@ struct PluginLazyActivationVerificationTests { let infoData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) - let suite = try EphemeralDefaults() - let defaults = suite.defaults - - let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir) + let manager = PluginManager(builtInPluginsURL: nil, userPluginsDir: userPluginsDir) let bundle = try #require(Bundle(url: bundleURL)) manager.plugins = [ PluginEntry( @@ -91,10 +83,7 @@ struct PluginLazyActivationVerificationTests { try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) try Data("not a mach-o binary".utf8).write(to: macosURL.appendingPathComponent("Broken")) - let suite = try EphemeralDefaults() - let defaults = suite.defaults - - let manager = PluginManager(userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: userPluginsDir) + let manager = PluginManager(builtInPluginsURL: nil, userPluginsDir: userPluginsDir) let bundle = try #require(Bundle(url: bundleURL)) manager.plugins = [ PluginEntry( diff --git a/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift b/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift index b49173d46b..23245e7f9a 100644 --- a/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift @@ -9,7 +9,6 @@ struct PluginLazyRegistrationTests { let manager: PluginManager let pluginsDir: URL let root: URL - let defaults: EphemeralDefaults } private func makeFixture() throws -> Fixture { @@ -17,9 +16,8 @@ struct PluginLazyRegistrationTests { .appendingPathComponent("PluginLazyRegistrationTests-\(UUID().uuidString)", isDirectory: true) let pluginsDir = root.appendingPathComponent("Plugins", isDirectory: true) try FileManager.default.createDirectory(at: pluginsDir, withIntermediateDirectories: true) - let defaults = try EphemeralDefaults() - let manager = PluginManager(userDefaults: defaults.defaults, builtInPluginsURL: nil, userPluginsDir: pluginsDir) - return Fixture(manager: manager, pluginsDir: pluginsDir, root: root, defaults: defaults) + let manager = PluginManager(builtInPluginsURL: nil, userPluginsDir: pluginsDir) + return Fixture(manager: manager, pluginsDir: pluginsDir, root: root) } // The executable is not Mach-O, so any attempt to load the bundle fails and is recorded. diff --git a/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift b/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift index cf5569dd4a..aebb4ed164 100644 --- a/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerInitialLoadTests.swift @@ -1,8 +1,3 @@ -// -// PluginManagerInitialLoadTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProTestSupport @@ -19,21 +14,18 @@ struct PluginManagerInitialLoadTests { let manager: PluginManager let clock: ManualClock let root: URL - let defaults: EphemeralDefaults } private func makeFixture() throws -> Fixture { let root = FileManager.default.temporaryDirectory .appendingPathComponent("PluginManagerInitialLoadTests-\(UUID().uuidString)", isDirectory: true) - let defaults = try EphemeralDefaults() let clock = ManualClock() let manager = PluginManager( - userDefaults: defaults.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true), initialLoadClock: clock ) - return Fixture(manager: manager, clock: clock, root: root, defaults: defaults) + return Fixture(manager: manager, clock: clock, root: root) } @Test("A wait before any load has started returns at once instead of sitting out its timeout") diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift deleted file mode 100644 index 3dbfc2af2f..0000000000 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ /dev/null @@ -1,233 +0,0 @@ -import Foundation -import Testing -@testable import TablePro - -@Suite("PluginManager reconciliation helpers", .serialized) -@MainActor -struct PluginManagerReconciliationTests { - - private func makeManifest(pluginIds: [String]) -> RegistryManifest { - 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: 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( - bundleId: String? = nil, - registryId: String? = nil, - isOutdated: Bool = true, - reason: String = "ABI mismatch", - providedDatabaseTypeIds: [String] = [] - ) -> RejectedPlugin { - RejectedPlugin( - url: URL(fileURLWithPath: "/tmp/test-\(UUID().uuidString).tableplugin"), - bundleId: bundleId, - registryId: registryId, - name: "Test", - reason: reason, - isOutdated: isOutdated, - providedDatabaseTypeIds: providedDatabaseTypeIds - ) - } - - private func makeRegistryPlugin(id: String = "com.example.driver", kitVersions: [Int]) throws -> RegistryPlugin { - RegistryPlugin( - id: id, - name: "Test Plugin", - iconName: nil, - databaseTypeIds: nil, - exportFormatIds: nil, - releases: kitVersions.map { makeRelease(kit: $0) } - ) - } - - private func kind(_ action: RejectedPluginAction) -> String { - switch action { - case .updateAvailable: "updateAvailable" - case .awaitingCompatibleBuild: "awaitingCompatibleBuild" - case .requiresAppUpdate: "requiresAppUpdate" - case .notInRegistry: "notInRegistry" - } - } - - @Test("rejectedAction awaits while the manifest is still loading") - func rejectedActionAwaitsWithoutManifest() throws { - let plugin = try makeRegistryPlugin(kitVersions: [18]) - let action = PluginManager.rejectedAction( - registryPlugin: plugin, manifestLoaded: false, kits: 18...18 - ) - #expect(kind(action) == "awaitingCompatibleBuild") - } - - @Test("rejectedAction reports notInRegistry when no manifest entry matches") - func rejectedActionNotInRegistry() { - let action = PluginManager.rejectedAction( - registryPlugin: nil, manifestLoaded: true, kits: 18...18 - ) - #expect(kind(action) == "notInRegistry") - } - - @Test("rejectedAction offers an update when a current-kit binary exists") - func rejectedActionUpdateAvailable() throws { - let plugin = try makeRegistryPlugin(kitVersions: [17, 18]) - let action = PluginManager.rejectedAction( - registryPlugin: plugin, manifestLoaded: true, kits: 18...18 - ) - #expect(kind(action) == "updateAvailable") - } - - @Test("rejectedAction offers an update for a resilient older-kit binary under a newer app") - func rejectedActionUpdateAvailableForwardCompat() throws { - let plugin = try makeRegistryPlugin(kitVersions: [18]) - let action = PluginManager.rejectedAction( - registryPlugin: plugin, manifestLoaded: true, kits: 18...19 - ) - #expect(kind(action) == "updateAvailable") - } - - @Test("rejectedAction asks for an app update when only a newer-kit binary exists") - func rejectedActionRequiresAppUpdate() throws { - let plugin = try makeRegistryPlugin(kitVersions: [18, 19]) - let action = PluginManager.rejectedAction( - registryPlugin: plugin, manifestLoaded: true, kits: 17...17 - ) - #expect(kind(action) == "requiresAppUpdate") - } - - @Test("rejectedAction awaits when only pre-floor binaries are published") - func rejectedActionAwaitsForOlderKits() throws { - let plugin = try makeRegistryPlugin(kitVersions: [16, 17]) - let action = PluginManager.rejectedAction( - registryPlugin: plugin, manifestLoaded: true, kits: 18...18 - ) - #expect(kind(action) == "awaitingCompatibleBuild") - } - - @Test("retriggerReconciliation does nothing when no plugin is outdated") - func retriggerReconciliationNoopWhenNoneOutdated() { - let pm = PluginManager.shared - let savedRejected = pm.rejectedPlugins - let savedActive = pm.reconciliationActive - pm.rejectedPlugins = [] - pm.reconciliationActive = false - defer { - pm.rejectedPlugins = savedRejected - pm.reconciliationActive = savedActive - } - pm.retriggerReconciliation() - #expect(pm.reconciliationActive == false) - } - - @Test("ensurePluginReady returns without reconciling when the type has no outdated rejection") - func ensurePluginReadyNoopForUnknownType() async { - let pm = PluginManager.shared - let savedRejected = pm.rejectedPlugins - let savedActive = pm.reconciliationActive - pm.rejectedPlugins = [] - pm.reconciliationActive = false - defer { - pm.rejectedPlugins = savedRejected - pm.reconciliationActive = savedActive - } - await pm.ensurePluginReady(forTypeId: "com.example.absent") - #expect(pm.reconciliationActive == false) - } - - @Test("resolveRegistryId prefers explicit registryId from sidecar") - func resolveRegistryIdUsesRegistryId() { - let pm = PluginManager.shared - let manifest = makeManifest(pluginIds: ["com.example.driver"]) - let rejected = makeRejected(bundleId: "com.example.driver", registryId: "com.example.driver") - let resolved = pm.resolveRegistryId(for: rejected, manifest: manifest) - #expect(resolved == "com.example.driver") - } - - @Test("resolveRegistryId falls back to bundleId when sidecar missing") - func resolveRegistryIdFallsBackToBundleId() { - let pm = PluginManager.shared - let manifest = makeManifest(pluginIds: ["com.example.driver"]) - let rejected = makeRejected(bundleId: "com.example.driver", registryId: nil) - let resolved = pm.resolveRegistryId(for: rejected, manifest: manifest) - #expect(resolved == "com.example.driver") - } - - @Test("resolveRegistryId returns nil when no match in manifest") - func resolveRegistryIdReturnsNilForUnknown() { - let pm = PluginManager.shared - let manifest = makeManifest(pluginIds: ["com.example.other"]) - let rejected = makeRejected(bundleId: "com.example.driver", registryId: nil) - let resolved = pm.resolveRegistryId(for: rejected, manifest: manifest) - #expect(resolved == nil) - } - - @Test("removeFromRejected drops entries with matching URL") - func removeFromRejectedRemovesByURL() { - let pm = PluginManager.shared - let rejected = makeRejected(bundleId: "com.example.driver", registryId: "com.example.driver") - pm.rejectedPlugins.append(rejected) - let url = rejected.url - pm.removeFromRejected(url: url) - #expect(!pm.rejectedPlugins.contains { $0.url == url }) - } - - @Test("connect treats an outdated installed plugin as updatable, not missing") - func hasOutdatedRejectedPluginMatchesType() { - let pm = PluginManager.shared - let rejected = makeRejected(bundleId: "com.example.driver", providedDatabaseTypeIds: ["TestDriverType"]) - pm.rejectedPlugins.append(rejected) - defer { pm.removeFromRejected(url: rejected.url) } - #expect(pm.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) - #expect(!pm.hasOutdatedRejectedPlugin(forTypeId: "OtherDriverType")) - } - - @Test("plugins rejected for non-ABI reasons are not treated as updatable") - func hasOutdatedRejectedPluginIgnoresNonOutdated() { - let pm = PluginManager.shared - let rejected = makeRejected(isOutdated: false, providedDatabaseTypeIds: ["TestDriverType"]) - pm.rejectedPlugins.append(rejected) - defer { pm.removeFromRejected(url: rejected.url) } - #expect(!pm.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) - } - - @Test("incompatible-build errors are permanent reconciliation failures") - func permanentFailuresClassified() { - #expect(PluginError.noCompatibleRelease.isPermanentReconciliationFailure) - #expect(PluginError.incompatibleVersion(required: 15, current: 14).isPermanentReconciliationFailure) - #expect(PluginError.appTooOldForPlugin(oldestPublishedKit: 15, appKit: 14).isPermanentReconciliationFailure) - } - - @Test("transient errors are retried, not surfaced as permanent failures") - func transientFailuresNotPermanent() { - #expect(!PluginError.downloadFailed("timeout").isPermanentReconciliationFailure) - #expect(!PluginError.checksumMismatch.isPermanentReconciliationFailure) - #expect(!PluginError.installFailed("io error").isPermanentReconciliationFailure) - } - - @Test("reconciliation retries only when a transient failure still has attempts left") - func reconciliationRetryDecision() { - #expect(PluginManager.reconciliationShouldRetry(sawTransientFailure: true, retryRemaining: true)) - #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: true, retryRemaining: false)) - #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: false, retryRemaining: true)) - #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: false, retryRemaining: false)) - } -} diff --git a/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift b/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift new file mode 100644 index 0000000000..f059ab4ce8 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift @@ -0,0 +1,789 @@ +import Foundation +@testable import TablePro +import TableProPluginKit +import TableProTestSupport +import Testing + +private final class ManifestStubProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var bodies: [URL: Data] = [:] + nonisolated(unsafe) private static var requests: [URL: Int] = [:] + + static func serve(_ body: Data, at url: URL) { + lock.withLock { bodies[url] = body } + } + + static func requestCount(for url: URL) -> Int { + lock.withLock { requests[url, default: 0] } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + guard let url = request.url, + let response = HTTPURLResponse( + url: url, statusCode: 200, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) + else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + let body = Self.lock.withLock { + Self.requests[url, default: 0] += 1 + return Self.bodies[url] ?? Data() + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } +} + +@discardableResult +private func writeBundle( + at url: URL, + bundleId: String, + build: Int, + kit: Int = PluginManager.currentPluginKitVersion, + extraInfo: [String: Any] = [:] +) throws -> URL { + let contents = url.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + var info: [String: Any] = [ + "CFBundleIdentifier": bundleId, + "CFBundleShortVersionString": "1.0", + "CFBundleVersion": String(build), + "TableProPluginKitVersion": kit + ] + info.merge(extraInfo) { _, new in new } + let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + return url +} + +// Writes an unsigned bundle where the signed download would land, then places it with the installer's helpers. +private actor FixtureRegistryInstaller: RegistryPluginInstalling { + private(set) var installedIds: [String] = [] + private(set) var stagedIds: [String] = [] + private let installGate: AsyncGate? + private let stageGate: AsyncGate? + + init(installGate: AsyncGate? = nil, stageGate: AsyncGate? = nil) { + self.installGate = installGate + self.stageGate = stageGate + } + + func install( + _ registryPlugin: RegistryPlugin, + release: RegistryRelease, + into userPluginsDir: URL, + progress: @escaping @Sendable (Double) async -> Void + ) async throws -> URL { + installedIds.append(registryPlugin.id) + await installGate?.wait() + let download = try downloadedBundle(registryPlugin, release: release, beside: userPluginsDir, named: "Driver.tableplugin") + let destURL = userPluginsDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + return try PluginInstaller.atomicReplace(stagedBundleURL: download, destURL: destURL) + } + + func stage( + _ registryPlugin: RegistryPlugin, + release: RegistryRelease, + pluginId: String, + bundleName: String, + into userPluginsDir: URL, + progress: @escaping @Sendable (Double) async -> Void + ) async throws -> URL { + stagedIds.append(registryPlugin.id) + await stageGate?.wait() + let download = try downloadedBundle(registryPlugin, release: release, beside: userPluginsDir, named: bundleName) + return try PluginInstaller.stage( + bundleAt: download, + pluginId: pluginId, + bundleName: bundleName, + userPluginsDir: userPluginsDir + ) + } + + private func downloadedBundle( + _ registryPlugin: RegistryPlugin, + release: RegistryRelease, + beside userPluginsDir: URL, + named bundleName: String + ) throws -> URL { + let url = userPluginsDir.deletingLastPathComponent() + .appendingPathComponent("Download-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(bundleName, isDirectory: true) + return try writeBundle(at: url, bundleId: registryPlugin.id, build: release.build) + } +} + +@Suite("Plugin registry update pass", .serialized) +@MainActor +struct PluginRegistryUpdatePassTests { + private struct Fixture { + let manager: PluginManager + let pluginsDir: URL + let root: URL + } + + private func makeFixture( + installer: any RegistryPluginInstalling = PluginInstaller.shared + ) throws -> Fixture { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("PluginRegistryUpdatePassTests-\(UUID().uuidString)", isDirectory: true) + let pluginsDir = root.appendingPathComponent("Plugins", isDirectory: true) + try FileManager.default.createDirectory(at: pluginsDir, withIntermediateDirectories: true) + let manager = PluginManager(builtInPluginsURL: nil, userPluginsDir: pluginsDir, registryInstaller: installer) + return Fixture(manager: manager, pluginsDir: pluginsDir, root: root) + } + + private func stageUpdate( + of bundleId: String, + stagedAs pluginId: String? = nil, + build: Int, + kit: Int = PluginManager.currentPluginKitVersion, + extraInfo: [String: Any] = [:], + in fixture: Fixture + ) throws { + let downloaded = try writeBundle( + at: fixture.root.appendingPathComponent("Download-\(UUID().uuidString)/Driver.tableplugin", isDirectory: true), + bundleId: bundleId, + build: build, + kit: kit, + extraInfo: extraInfo + ) + try PluginInstaller.stage( + bundleAt: downloaded, + pluginId: pluginId ?? bundleId, + bundleName: "Driver.tableplugin", + userPluginsDir: fixture.pluginsDir + ) + } + + private func makeEntry( + id: String, + in pluginsDir: URL, + build: Int, + source: PluginSource = .userInstalled + ) -> PluginEntry { + PluginEntry( + id: id, + bundle: Bundle.main, + url: pluginsDir.appendingPathComponent("\(id).tableplugin", isDirectory: true), + source: source, + name: id, + version: "1.0", + build: build, + pluginDescription: "", + capabilities: [.databaseDriver], + databaseTypeId: nil, + additionalTypeIds: [], + pluginIconName: "puzzlepiece", + defaultPort: nil, + exportFormatId: nil, + importFormatId: nil + ) + } + + private func makeRelease(build: Int, kit: Int = PluginManager.currentPluginKitVersion) -> RegistryRelease { + RegistryRelease( + version: "0.82.0", + build: build, + channel: "stable", + pluginKit: kit, + minimumSystemVersion: "13.0", + url: "https://x", + sha256: "deadbeef" + ) + } + + private func makeRegistryPlugin(id: String = "com.example.driver", releases: [RegistryRelease]) -> RegistryPlugin { + RegistryPlugin( + id: id, + name: "Test Plugin", + iconName: nil, + databaseTypeIds: nil, + exportFormatIds: nil, + releases: releases + ) + } + + private func makeManifest(_ plugins: [RegistryPlugin]) -> RegistryManifest { + RegistryManifest(schemaVersion: RegistryManifest.supportedSchemaVersion, plugins: plugins) + } + + private func makeRejected( + bundleId: String? = nil, + registryId: String? = nil, + isOutdated: Bool = true, + providedDatabaseTypeIds: [String] = [] + ) -> RejectedPlugin { + RejectedPlugin( + url: URL(fileURLWithPath: "/tmp/test-\(UUID().uuidString).tableplugin"), + bundleId: bundleId, + registryId: registryId, + name: "Test", + reason: "Built for an older PluginKit", + isOutdated: isOutdated, + providedDatabaseTypeIds: providedDatabaseTypeIds + ) + } + + private func pending( + _ targets: [RegistryUpdateTarget], + in manifest: RegistryManifest + ) -> [PendingRegistryUpdate] { + PluginManager.pendingRegistryUpdates( + installed: targets, + manifest: manifest, + channel: .stable, + kits: PluginManager.compatibleKits, + systemVersion: OperatingSystemVersion(majorVersion: 26, minorVersion: 0, patchVersion: 0) + ) + } + + private func makeRegistryClient( + servingBuild build: Int, + pluginIds: [String] = ["com.example.driver"] + ) throws -> (client: RegistryClient, manifestURL: URL) { + let manifestURL = try #require(URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json")) + let plugins = pluginIds.map { id in + """ + {"id": "\(id)", "name": "Driver", "releases": [{ + "version": "1.0", "build": \(build), "channel": "stable", + "pluginKit": \(PluginManager.currentPluginKitVersion), + "minimumSystemVersion": "13.0", "url": "https://x", "sha256": "deadbeef" + }]} + """ + } + ManifestStubProtocol.serve(Data(""" + {"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": [\(plugins.joined(separator: ","))]} + """.utf8), at: manifestURL) + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ManifestStubProtocol.self] + let client = RegistryClient(manifestURL: manifestURL, session: URLSession(configuration: configuration)) + return (client, manifestURL) + } + + // MARK: - Pending updates + + @Test("A lower installed build updates; an equal build does not") + func lowerBuildUpdatesEqualBuildDoesNot() { + let manifest = makeManifest([ + makeRegistryPlugin(id: "com.example.lower", releases: [makeRelease(build: 820_100)]), + makeRegistryPlugin(id: "com.example.equal", releases: [makeRelease(build: 820_100)]) + ]) + let targets = [ + RegistryUpdateTarget(pluginId: "com.example.lower", build: 820_000, rejectedURL: nil), + RegistryUpdateTarget(pluginId: "com.example.equal", build: 820_100, rejectedURL: nil) + ] + + let updates = pending(targets, in: manifest) + let updatedIds = updates.map { $0.target.pluginId } + + #expect(updatedIds == ["com.example.lower"]) + #expect(updates.first?.release.build == 820_100) + } + + @Test("A rejected plugin counts as build 0, so any selectable release repairs it") + func rejectedPluginTakesAnySelectableRelease() { + let manifest = makeManifest([makeRegistryPlugin(releases: [makeRelease(build: 1)])]) + let target = RegistryUpdateTarget( + pluginId: "com.example.driver", + build: 0, + rejectedURL: URL(fileURLWithPath: "/tmp/Driver.tableplugin") + ) + + let builds = pending([target], in: manifest).map { $0.release.build } + + #expect(builds == [1]) + } + + @Test("A release outside the PluginKit range is never an update") + func releaseOutsideKitRangeSkipped() { + let manifest = makeManifest([ + makeRegistryPlugin(releases: [makeRelease(build: 900_000, kit: PluginManager.currentPluginKitVersion + 1)]) + ]) + let target = RegistryUpdateTarget(pluginId: "com.example.driver", build: 1, rejectedURL: nil) + + #expect(pending([target], in: manifest).isEmpty) + } + + // MARK: - Targets + + @Test("A user plugin below the app build with a registry sidecar is a target; without a sidecar it never is") + func sidecarDecidesTarget() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let withSidecar = makeEntry(id: "com.example.registry", in: fixture.pluginsDir, build: 10) + let withoutSidecar = makeEntry(id: "com.example.local", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: withSidecar.id, pluginURL: withSidecar.url) + manager.plugins = [withSidecar, withoutSidecar] + + let targets = manager.registryUpdateTargets(appBuild: 20) + + #expect(targets == [RegistryUpdateTarget(pluginId: "com.example.registry", build: 10, rejectedURL: nil)]) + } + + @Test("A plugin at the app build, a built-in plugin and a staged plugin are not targets") + func currentBuiltInAndStagedAreNotTargets() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let current = makeEntry(id: "com.example.current", in: fixture.pluginsDir, build: 20) + let builtIn = makeEntry(id: "com.example.builtin", in: fixture.pluginsDir, build: 10, source: .builtIn) + let staged = makeEntry(id: "com.example.staged", in: fixture.pluginsDir, build: 10) + for entry in [current, builtIn, staged] { + manager.saveRegistryMetadata(pluginId: entry.id, pluginURL: entry.url) + } + manager.plugins = [current, builtIn, staged] + manager.stagedPluginIds = [staged.id] + + #expect(manager.registryUpdateTargets(appBuild: 20).isEmpty) + } + + @Test("Rejected plugins are updated before loaded ones") + func rejectedTargetsComeFirst() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let loaded = makeEntry(id: "com.example.loaded", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: loaded.id, pluginURL: loaded.url) + manager.plugins = [loaded] + manager.rejectedPlugins = [makeRejected(bundleId: "com.example.rejected")] + + let order = manager.registryUpdateTargets(appBuild: 20).map { $0.pluginId } + + #expect(order == ["com.example.rejected", "com.example.loaded"]) + } + + @Test("A plugin rejected as outdated is a target at build 0; one rejected for another reason is not") + func outdatedRejectionsAreTargets() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let outdated = makeRejected(bundleId: "com.example.outdated") + let broken = makeRejected(bundleId: "com.example.broken", isOutdated: false) + manager.rejectedPlugins = [outdated, broken] + + let targets = manager.registryUpdateTargets(appBuild: 20) + + #expect(targets == [RegistryUpdateTarget(pluginId: "com.example.outdated", build: 0, rejectedURL: outdated.url)]) + } + + @Test("A target uninstalled, removed, staged or replaced by a file while the pass waits is skipped") + func targetsAreRevalidatedBeforeApplying() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let entry = makeEntry(id: "com.example.driver", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: entry.id, pluginURL: entry.url) + let rejected = makeRejected(bundleId: "com.example.outdated") + manager.plugins = [entry] + manager.rejectedPlugins = [rejected] + let loadedTarget = RegistryUpdateTarget(pluginId: entry.id, build: 10, rejectedURL: nil) + let rejectedTarget = RegistryUpdateTarget(pluginId: "com.example.outdated", build: 0, rejectedURL: rejected.url) + #expect(manager.isRegistryUpdateTarget(loadedTarget)) + #expect(manager.isRegistryUpdateTarget(rejectedTarget)) + + manager.stagedPluginIds = [entry.id] + #expect(!manager.isRegistryUpdateTarget(loadedTarget)) + manager.stagedPluginIds = [] + + manager.plugins = [makeEntry(id: entry.id, in: fixture.pluginsDir, build: 30)] + #expect(!manager.isRegistryUpdateTarget(loadedTarget)) + + manager.plugins = [entry] + manager.removeRegistryMetadata(for: entry.url) + #expect(!manager.isRegistryUpdateTarget(loadedTarget)) + + manager.plugins = [] + manager.removeFromRejected(url: rejected.url) + #expect(!manager.isRegistryUpdateTarget(loadedTarget)) + #expect(!manager.isRegistryUpdateTarget(rejectedTarget)) + } + + // MARK: - The pass + + @Test("No targets means no manifest fetch") + func noTargetsNoFetch() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let registry = try OfflineRegistry() + + let result = await fixture.manager.runRegistryUpdatePass(registryClient: registry.client, appBuild: 20) + + #expect(result == .finished) + #expect(registry.requestCount == 0) + } + + @Test("A manifest without a newer build yet keeps the pass retrying") + func staleManifestRetries() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let entry = makeEntry(id: "com.example.driver", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: entry.id, pluginURL: entry.url) + manager.plugins = [entry] + let (client, manifestURL) = try makeRegistryClient(servingBuild: 10) + + let result = await manager.runRegistryUpdatePass(registryClient: client, appBuild: 20) + + #expect(result == .retry) + #expect(ManifestStubProtocol.requestCount(for: manifestURL) == 1) + #expect(manager.stagedPluginIds.isEmpty) + } + + @Test("Retries wait 30 s, 300 s, then 600 s, and stop after the fifth attempt") + func retryBackoff() { + let delays = (1...5).map { PluginManager.registryUpdateRetryDelay(afterAttempt: $0) } + + #expect(delays == [.seconds(30), .seconds(300), .seconds(600), .seconds(600), nil]) + } + + @Test("registryUpdateMode installs a plugin that is not loaded and stages a loaded one") + func updateModeRouting() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + manager.plugins = [makeEntry(id: "com.example.loaded", in: fixture.pluginsDir, build: 10)] + manager.rejectedPlugins = [makeRejected(bundleId: "com.example.rejected")] + + #expect(manager.registryUpdateMode(forPluginId: "com.example.loaded") == .stageForNextLaunch) + #expect(manager.registryUpdateMode(forPluginId: "com.example.rejected") == .installNow) + #expect(manager.registryUpdateMode(forPluginId: "com.example.absent") == .installNow) + } + + @Test("The pass stages a loaded plugin's newer build for the next launch and keeps its sidecar") + func passStagesLoadedPlugin() async throws { + let installer = FixtureRegistryInstaller() + let fixture = try makeFixture(installer: installer) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let entry = makeEntry(id: "com.example.driver", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: entry.id, pluginURL: entry.url) + manager.plugins = [entry] + let (client, _) = try makeRegistryClient(servingBuild: 20) + + let result = await manager.runRegistryUpdatePass(registryClient: client, appBuild: 20) + + #expect(result == .finished) + #expect(manager.stagedPluginIds == [entry.id]) + let stagedBundle = PluginInstaller.stagedUpdateDirectory(for: entry.id, in: fixture.pluginsDir) + .appendingPathComponent(entry.url.lastPathComponent, isDirectory: true) + #expect(PluginManager.bundleBuild(at: stagedBundle) == 20) + #expect(PluginManager.readRegistryMetadata(for: entry.url)?.pluginId == entry.id) + let installedIds = await installer.installedIds + #expect(installedIds.isEmpty) + } + + @Test("The pass installs a rejected plugin at once, and a build that fails to load keeps it rejected") + func passInstallsRejectedPluginNow() async throws { + let installer = FixtureRegistryInstaller() + let fixture = try makeFixture(installer: installer) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let rejectedURL = fixture.pluginsDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + manager.rejectedPlugins = [RejectedPlugin( + url: rejectedURL, + bundleId: "com.example.driver", + registryId: nil, + name: "Driver", + reason: "Built for an older PluginKit", + isOutdated: true, + providedDatabaseTypeIds: [] + )] + let (client, _) = try makeRegistryClient(servingBuild: 20) + + let result = await manager.runRegistryUpdatePass(registryClient: client, appBuild: 20) + + let installedIds = await installer.installedIds + let stagedIds = await installer.stagedIds + #expect(installedIds == ["com.example.driver"]) + #expect(stagedIds.isEmpty) + #expect(manager.stagedPluginIds.isEmpty) + #expect(PluginManager.bundleBuild(at: rejectedURL) == 20) + // The fixture is unsigned, so the load fails and the entry stays. + #expect(result == .retry) + #expect(manager.rejectedPlugins.map { $0.url } == [rejectedURL]) + } + + @Test("A connection waits for the pass's rejected repairs, not for its staging downloads") + func repairPhaseEndsBeforeStaging() async throws { + let installGate = AsyncGate() + let stageGate = AsyncGate() + let installer = FixtureRegistryInstaller(installGate: installGate, stageGate: stageGate) + let fixture = try makeFixture(installer: installer) + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let loaded = makeEntry(id: "com.example.loaded", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: loaded.id, pluginURL: loaded.url) + manager.plugins = [loaded] + manager.rejectedPlugins = [makeRejected(bundleId: "com.example.driver")] + let (client, _) = try makeRegistryClient(servingBuild: 20, pluginIds: ["com.example.driver", loaded.id]) + + let pass = Task { await manager.runRegistryUpdatePass(registryClient: client, appBuild: 20) } + await installGate.arrived(1) + #expect(manager.isRepairingRejectedPlugins) + await installGate.open() + await stageGate.arrived(1) + #expect(!manager.isRepairingRejectedPlugins) + await stageGate.open() + _ = await pass.value + + #expect(manager.stagedPluginIds == [loaded.id]) + } + + @Test("A file staged over a loaded registry plugin drops its sidecar, so the pass leaves it alone") + func fileStagedOverRegistryPluginDropsSidecar() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let entry = makeEntry(id: "com.example.driver", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: entry.id, pluginURL: entry.url) + manager.plugins = [entry] + let file = try writeBundle( + at: fixture.root.appendingPathComponent("Downloads/Driver.tableplugin", isDirectory: true), + bundleId: entry.id, + build: 11 + ) + + _ = try manager.stageForNextLaunch(bundleAt: file, replacing: entry) + + #expect(manager.stagedPluginIds == [entry.id]) + #expect(PluginManager.readRegistryMetadata(for: entry.url) == nil) + manager.stagedPluginIds = [] + #expect(manager.registryUpdateTargets(appBuild: 20).isEmpty) + } + + @Test("Connecting repairs only the outdated plugins that provide its database type") + func connectTargetsOnlyThatType() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + let loaded = makeEntry(id: "com.example.loaded", in: fixture.pluginsDir, build: 10) + manager.saveRegistryMetadata(pluginId: loaded.id, pluginURL: loaded.url) + manager.plugins = [loaded] + let wanted = makeRejected(bundleId: "com.example.wanted", providedDatabaseTypeIds: ["WantedDB"]) + let other = makeRejected(bundleId: "com.example.other", providedDatabaseTypeIds: ["OtherDB"]) + manager.rejectedPlugins = [wanted, other] + + let targets = manager.registryUpdateTargets(forTypeId: "WantedDB", appBuild: 20) + + #expect(targets == [RegistryUpdateTarget(pluginId: "com.example.wanted", build: 0, rejectedURL: wanted.url)]) + } + + @Test("A rejected plugin replaced in place passes the kit check at once") + func inPlaceRepairPassesKitCheck() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let url = fixture.pluginsDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + let outdatedKit = PluginManager.minimumCompatiblePluginKitVersion - 1 + try writeBundle(at: url, bundleId: "com.example.driver", build: 1, kit: outdatedKit) + let cached = try #require(Bundle(url: url)) + #expect(throws: PluginError.self) { try PluginManager.validateBundleVersions(cached) } + + try writeBundle(at: url, bundleId: "com.example.driver", build: 2) + + #expect(cached.infoDictionary?["TableProPluginKitVersion"] as? Int == outdatedKit) + try PluginManager.validateBundleVersions(try #require(Bundle(url: url))) + } + + // MARK: - Launch swap + + @Test("Launch applies a staged bundle before discovery and removes the staging copy") + func launchAppliesStagedBundle() async throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let bundleId = "com.example.updatepass.\(UUID().uuidString)" + let provides: [String: Any] = ["TableProProvidesDatabaseTypeIds": ["UpdatePassDB-\(UUID().uuidString)"]] + let installedURL = fixture.pluginsDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + try writeBundle(at: installedURL, bundleId: bundleId, build: 1, extraInfo: provides) + try stageUpdate(of: bundleId, build: 2, extraInfo: provides, in: fixture) + let manager = fixture.manager + + manager.loadPlugins() + await manager.waitForInitialLoad() + + #expect(manager.plugins.first { $0.id == bundleId }?.build == 2) + #expect(PluginManager.bundleBuild(at: installedURL) == 2) + let stagedDir = PluginInstaller.stagedUpdateDirectory(for: bundleId, in: fixture.pluginsDir) + #expect(!FileManager.default.fileExists(atPath: stagedDir.path)) + } + + @Test("A staged bundle with another plugin's id is dropped and the installed copy stays") + func mismatchedStagedBundleKeepsInstalledCopy() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let installedURL = fixture.pluginsDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + try writeBundle(at: installedURL, bundleId: "com.example.driver", build: 1) + try stageUpdate(of: "com.example.other", stagedAs: "com.example.driver", build: 2, in: fixture) + + PluginManager.applyStagedUpdates(in: fixture.pluginsDir) + + #expect(PluginManager.bundleBuild(at: installedURL) == 1) + let stagedDir = PluginInstaller.stagedUpdateDirectory(for: "com.example.driver", in: fixture.pluginsDir) + #expect(!FileManager.default.fileExists(atPath: stagedDir.path)) + } + + @Test("A staged bundle outside the PluginKit range is dropped and the installed copy stays") + func incompatibleStagedBundleKeepsInstalledCopy() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let installedURL = fixture.pluginsDir.appendingPathComponent("Driver.tableplugin", isDirectory: true) + try writeBundle(at: installedURL, bundleId: "com.example.driver", build: 1) + try stageUpdate( + of: "com.example.driver", + build: 2, + kit: PluginManager.currentPluginKitVersion + 1, + in: fixture + ) + + PluginManager.applyStagedUpdates(in: fixture.pluginsDir) + + #expect(PluginManager.bundleBuild(at: installedURL) == 1) + } + + @Test("Uninstalling a plugin drops its staged update") + func removeStagedUpdateDropsTheCopy() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manager = fixture.manager + try stageUpdate(of: "com.example.driver", build: 2, in: fixture) + manager.stagedPluginIds = ["com.example.driver"] + + manager.removeStagedUpdate(pluginId: "com.example.driver") + + let stagedDir = PluginInstaller.stagedUpdateDirectory(for: "com.example.driver", in: fixture.pluginsDir) + #expect(!FileManager.default.fileExists(atPath: stagedDir.path)) + #expect(manager.stagedPluginIds.isEmpty) + } + + // MARK: - Rejected plugins + + private func kind(_ action: RejectedPluginAction) -> String { + switch action { + case .updateAvailable: "updateAvailable" + case .awaitingCompatibleBuild: "awaitingCompatibleBuild" + case .requiresAppUpdate: "requiresAppUpdate" + case .notInRegistry: "notInRegistry" + } + } + + private func makeKitPlugin(kits: [Int]) -> RegistryPlugin { + makeRegistryPlugin(releases: kits.map { makeRelease(build: 820_000 + $0, kit: $0) }) + } + + @Test("rejectedAction awaits while the manifest is still loading") + func rejectedActionAwaitsWithoutManifest() { + let action = PluginManager.rejectedAction( + registryPlugin: makeKitPlugin(kits: [18]), manifestLoaded: false, kits: 18...18 + ) + #expect(kind(action) == "awaitingCompatibleBuild") + } + + @Test("rejectedAction reports notInRegistry when no manifest entry matches") + func rejectedActionNotInRegistry() { + let action = PluginManager.rejectedAction(registryPlugin: nil, manifestLoaded: true, kits: 18...18) + #expect(kind(action) == "notInRegistry") + } + + @Test("rejectedAction offers an update when a current-kit release exists") + func rejectedActionUpdateAvailable() { + let action = PluginManager.rejectedAction( + registryPlugin: makeKitPlugin(kits: [17, 18]), manifestLoaded: true, kits: 18...18 + ) + #expect(kind(action) == "updateAvailable") + } + + @Test("rejectedAction offers an update for an older-kit release still in range") + func rejectedActionUpdateAvailableForwardCompat() { + let action = PluginManager.rejectedAction( + registryPlugin: makeKitPlugin(kits: [18]), manifestLoaded: true, kits: 18...19 + ) + #expect(kind(action) == "updateAvailable") + } + + @Test("rejectedAction asks for an app update when only a newer-kit release exists") + func rejectedActionRequiresAppUpdate() { + let action = PluginManager.rejectedAction( + registryPlugin: makeKitPlugin(kits: [18, 19]), manifestLoaded: true, kits: 17...17 + ) + #expect(kind(action) == "requiresAppUpdate") + } + + @Test("rejectedAction awaits when only pre-floor releases are published") + func rejectedActionAwaitsForOlderKits() { + let action = PluginManager.rejectedAction( + registryPlugin: makeKitPlugin(kits: [16, 17]), manifestLoaded: true, kits: 18...18 + ) + #expect(kind(action) == "awaitingCompatibleBuild") + } + + @Test("resolveRegistryId prefers the registry id from the sidecar") + func resolveRegistryIdUsesRegistryId() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manifest = makeManifest([makeRegistryPlugin(id: "com.example.driver", releases: [])]) + let rejected = makeRejected(bundleId: "com.example.driver", registryId: "com.example.driver") + + #expect(fixture.manager.resolveRegistryId(for: rejected, manifest: manifest) == "com.example.driver") + } + + @Test("resolveRegistryId falls back to the bundle id when the sidecar is missing") + func resolveRegistryIdFallsBackToBundleId() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manifest = makeManifest([makeRegistryPlugin(id: "com.example.driver", releases: [])]) + let rejected = makeRejected(bundleId: "com.example.driver") + + #expect(fixture.manager.resolveRegistryId(for: rejected, manifest: manifest) == "com.example.driver") + } + + @Test("resolveRegistryId returns nil when nothing in the manifest matches") + func resolveRegistryIdReturnsNilForUnknown() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let manifest = makeManifest([makeRegistryPlugin(id: "com.example.other", releases: [])]) + let rejected = makeRejected(bundleId: "com.example.driver") + + #expect(fixture.manager.resolveRegistryId(for: rejected, manifest: manifest) == nil) + } + + @Test("removeFromRejected drops the entry with that URL") + func removeFromRejectedRemovesByURL() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let rejected = makeRejected(bundleId: "com.example.driver") + fixture.manager.rejectedPlugins = [rejected] + + fixture.manager.removeFromRejected(url: rejected.url) + + #expect(fixture.manager.rejectedPlugins.isEmpty) + } + + @Test("Connect treats an outdated installed plugin as updatable, not missing") + func hasOutdatedRejectedPluginMatchesType() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.manager.rejectedPlugins = [ + makeRejected(bundleId: "com.example.driver", providedDatabaseTypeIds: ["TestDriverType"]) + ] + + #expect(fixture.manager.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) + #expect(!fixture.manager.hasOutdatedRejectedPlugin(forTypeId: "OtherDriverType")) + } + + @Test("A plugin rejected for another reason is not updatable") + func hasOutdatedRejectedPluginIgnoresNonOutdated() throws { + let fixture = try makeFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + fixture.manager.rejectedPlugins = [makeRejected(isOutdated: false, providedDatabaseTypeIds: ["TestDriverType"])] + + #expect(!fixture.manager.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) + } +} diff --git a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift index 96e538de9c..0f942cf5c9 100644 --- a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift +++ b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift @@ -1,8 +1,3 @@ -// -// PluginSignatureSweepTests.swift -// TableProTests -// - @testable import TablePro import TableProPluginKit import XCTest @@ -10,7 +5,6 @@ import XCTest @MainActor final class PluginSignatureSweepTests: XCTestCase { private var root: URL! - private var suite: EphemeralDefaults! private var manager: PluginManager! override func setUp() async throws { @@ -18,9 +12,7 @@ final class PluginSignatureSweepTests: XCTestCase { root = FileManager.default.temporaryDirectory .appendingPathComponent("PluginSignatureSweepTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - suite = try EphemeralDefaults() manager = PluginManager( - userDefaults: suite.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) @@ -28,7 +20,6 @@ final class PluginSignatureSweepTests: XCTestCase { override func tearDown() async throws { manager = nil - suite = nil if let root { try? FileManager.default.removeItem(at: root) } root = nil try await super.tearDown() diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index 399e11b578..79bf4969a1 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -45,12 +45,6 @@ private final class MockRegistryProtocol: URLProtocol, @unchecked Sendable { @Suite("RegistryClient freshness", .serialized) @MainActor struct RegistryClientFreshnessTests { - private let suite: EphemeralDefaults - - init() throws { - suite = try EphemeralDefaults() - } - private static let testTypeId = "TestFreshnessDB" private func manifestURL() throws -> URL { @@ -78,7 +72,7 @@ struct RegistryClientFreshnessTests { private func makeEnvironment( cachedManifest: Data? = nil - ) throws -> (client: RegistryClient, defaults: UserDefaults, tempDir: URL) { + ) throws -> (client: RegistryClient, tempDir: URL) { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("registry-freshness-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -97,12 +91,12 @@ struct RegistryClientFreshnessTests { config.protocolClasses = [MockRegistryProtocol.self] config.urlCache = cache let client = RegistryClient(manifestURL: url, session: URLSession(configuration: config)) - return (client, suite.defaults, tempDir) + return (client, tempDir) } @Test("The client asks for the manifest at the URL it was given") func requestsTheInjectedURL() throws { - let (client, _, _) = try makeEnvironment() + let (client, _) = try makeEnvironment() #expect(client.makeRequest(policy: .ifStale).url == client.manifestURL) #expect(client.manifestURL.host() == "registry.invalid") @@ -119,7 +113,7 @@ struct RegistryClientFreshnessTests { @Test("ifStale lets the HTTP cache answer, mustBeCurrent revalidates with the origin") func cachePolicies() throws { - let (client, _, _) = try makeEnvironment() + let (client, _) = try makeEnvironment() #expect(client.makeRequest(policy: .ifStale).cachePolicy == .useProtocolCachePolicy) #expect(client.makeRequest(policy: .mustBeCurrent).cachePolicy == .reloadRevalidatingCacheData) @@ -127,7 +121,7 @@ struct RegistryClientFreshnessTests { @Test("A cached v3 response is decoded when the client starts") func cachedManifestDecodedAtInit() throws { - let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) + let (client, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) #expect(client.manifest?.plugins.map(\.id) == ["com.test.freshness-driver"]) #expect(!client.isManifestCurrent) @@ -135,7 +129,7 @@ struct RegistryClientFreshnessTests { @Test("A cached v2 response is ignored") func cachedSchemaTwoIgnored() throws { - let (client, _, _) = try makeEnvironment( + let (client, _) = try makeEnvironment( cachedManifest: manifestData(includingTestPlugin: false, schemaVersion: 2) ) @@ -148,7 +142,7 @@ struct RegistryClientFreshnessTests { (200, manifestData(includingTestPlugin: false)), (200, manifestData(includingTestPlugin: false)) ]) - let (client, _, _) = try makeEnvironment() + let (client, _) = try makeEnvironment() async let first: Void = client.fetch(policy: .ifStale) async let second: Void = client.fetch(policy: .ifStale) @@ -164,7 +158,7 @@ struct RegistryClientFreshnessTests { (200, manifestData(includingTestPlugin: false)), (200, manifestData(includingTestPlugin: true)) ]) - let (client, _, _) = try makeEnvironment() + let (client, _) = try makeEnvironment() await client.fetch(policy: .ifStale) #expect(client.manifest?.plugins.isEmpty == true) @@ -177,7 +171,7 @@ struct RegistryClientFreshnessTests { @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)) + let (client, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) await client.fetch(policy: .mustBeCurrent) @@ -188,7 +182,7 @@ struct RegistryClientFreshnessTests { @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)) + let (client, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) await client.fetch(policy: .mustBeCurrent) @@ -201,10 +195,9 @@ struct RegistryClientFreshnessTests { let stale = manifestData(includingTestPlugin: false) let fresh = manifestData(includingTestPlugin: true) MockRegistryProtocol.reset(responses: [(200, stale), (200, fresh)]) - let (client, defaults, tempDir) = try makeEnvironment(cachedManifest: stale) + let (client, tempDir) = try makeEnvironment(cachedManifest: stale) let manager = PluginManager( - userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) ) @@ -230,10 +223,9 @@ struct RegistryClientFreshnessTests { @Test("unreachable registry reports a connection problem, not a missing plugin") func unreachableRegistryReportsConnectionProblem() async throws { MockRegistryProtocol.reset(responses: [(500, Data()), (500, Data())]) - let (client, defaults, tempDir) = try makeEnvironment() + let (client, tempDir) = try makeEnvironment() let manager = PluginManager( - userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) ) @@ -256,9 +248,8 @@ struct RegistryClientFreshnessTests { func exportFormatResolvesByRegistryId() async throws { let fresh = manifestData(includingTestPlugin: true) MockRegistryProtocol.reset(responses: [(200, fresh)]) - let (client, defaults, tempDir) = try makeEnvironment() + let (client, tempDir) = try makeEnvironment() let manager = PluginManager( - userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) ) @@ -283,9 +274,8 @@ struct RegistryClientFreshnessTests { func exportFormatMissingFromRegistry() async throws { let empty = manifestData(includingTestPlugin: false) MockRegistryProtocol.reset(responses: [(200, empty), (200, empty)]) - let (client, defaults, tempDir) = try makeEnvironment() + let (client, tempDir) = try makeEnvironment() let manager = PluginManager( - userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) ) diff --git a/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift b/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift index 014c5cbd6c..d9ee04a49d 100644 --- a/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift +++ b/TableProTests/Core/Services/Import/MacImportLibraryStoreTests.swift @@ -1,8 +1,3 @@ -// -// MacImportLibraryStoreTests.swift -// TableProTests -// - import Foundation @testable import TablePro import TableProImport @@ -275,9 +270,7 @@ struct MacImportLibraryStoreTests { let root = FileManager.default.temporaryDirectory .appendingPathComponent("MacImportEnvironment-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } - let suite = try EphemeralDefaults() let manager = PluginManager( - userDefaults: suite.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) diff --git a/TableProTests/Services/DebugUpdateGateTests.swift b/TableProTests/Services/DebugUpdateGateTests.swift index e1d8bd1a0f..5b8cab85d9 100644 --- a/TableProTests/Services/DebugUpdateGateTests.swift +++ b/TableProTests/Services/DebugUpdateGateTests.swift @@ -19,14 +19,12 @@ struct DebugUpdateGateTests { #expect(!AppDelegate().validateMenuItem(item)) } - @Test("A Debug plugin load starts neither the plugin update pass nor its network monitor") + @Test("A Debug plugin load does not start the plugin update pass") func pluginLoadSkipsTheUpdatePass() async throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("DebugUpdateGateTests-\(UUID().uuidString)", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } - let defaults = try EphemeralDefaults() let manager = PluginManager( - userDefaults: defaults.defaults, builtInPluginsURL: nil, userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) ) @@ -35,7 +33,6 @@ struct DebugUpdateGateTests { await manager.waitForInitialLoad() #expect(manager.hasFinishedInitialLoad) - #expect(manager.reconciliationTask == nil) - #expect(manager.pluginNetworkMonitor == nil) + #expect(manager.registryUpdateTask == nil) } } diff --git a/TableProTests/ViewModels/WelcomeViewModelTests.swift b/TableProTests/ViewModels/WelcomeViewModelTests.swift index 70bd0cecb9..c6ee35392e 100644 --- a/TableProTests/ViewModels/WelcomeViewModelTests.swift +++ b/TableProTests/ViewModels/WelcomeViewModelTests.swift @@ -781,7 +781,6 @@ final class WelcomeViewModelTests { @Test func testARejectedPluginFlagsEveryConnectionOfTheTypesItProvides() { let pluginManager = PluginManager( - userDefaults: defaults, builtInPluginsURL: nil, userPluginsDir: connectionFileURL.deletingLastPathComponent().appendingPathComponent("Plugins") ) diff --git a/changelog.d/3507.changed.md b/changelog.d/3507.changed.md new file mode 100644 index 0000000000..f6e8196670 --- /dev/null +++ b/changelog.d/3507.changed.md @@ -0,0 +1 @@ +Registry plugins update after a TablePro update, not from an Update button; a loaded one updates at the next launch.