From 4b43df32f57e391137121fe8b744ef0eeaf693df Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 19:29:27 +0700 Subject: [PATCH 1/2] fix(plugins): update incompatible plugins quietly after an app ABI bump (#1322) --- .github/scripts/test_update_registry.py | 91 +++++++++++++++++++ .github/scripts/update-registry.py | 18 +++- CHANGELOG.md | 5 + TablePro/Core/Plugins/PluginError.swift | 11 ++- .../Plugins/PluginManager+AutoUpdate.swift | 81 +++++++++++++---- .../Core/Plugins/PluginManager+Install.swift | 9 +- TablePro/Core/Plugins/PluginManager.swift | 1 + .../PluginManagerReconciliationTests.swift | 15 +++ .../RegistryBinarySelectionTests.swift | 10 ++ 9 files changed, 217 insertions(+), 24 deletions(-) create mode 100644 .github/scripts/test_update_registry.py diff --git a/.github/scripts/test_update_registry.py b/.github/scripts/test_update_registry.py new file mode 100644 index 000000000..62357803e --- /dev/null +++ b/.github/scripts/test_update_registry.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Tests for update-registry.py PluginKit version pruning (#1322). + +Run: python3 .github/scripts/test_update_registry.py +""" +import importlib.util +import os + +_spec = importlib.util.spec_from_file_location( + "update_registry", + os.path.join(os.path.dirname(__file__), "update-registry.py"), +) +update_registry = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(update_registry) + + +def test_kit_version_rejects_non_int(): + assert update_registry.kit_version({"pluginKitVersion": 14}) == 14 + assert update_registry.kit_version({"pluginKitVersion": None}) is None + assert update_registry.kit_version({}) is None + + +def test_prune_drops_null_kit_binary(): + binaries = [ + {"architecture": "arm64", "pluginKitVersion": None, "downloadURL": "legacy"}, + {"architecture": "arm64", "pluginKitVersion": 14, "downloadURL": "v14"}, + {"architecture": "arm64", "pluginKitVersion": 13, "downloadURL": "v13"}, + ] + kept = update_registry.prune_old_kit_versions(binaries, keep_count=2) + versions = sorted(update_registry.kit_version(b) for b in kept) + assert versions == [13, 14], versions + assert all(update_registry.kit_version(b) is not None for b in kept) + + +def test_prune_keeps_only_two_newest(): + binaries = [ + {"architecture": "arm64", "pluginKitVersion": v, "downloadURL": str(v)} + for v in (12, 13, 14) + ] + kept = update_registry.prune_old_kit_versions(binaries, keep_count=2) + versions = sorted(update_registry.kit_version(b) for b in kept) + assert versions == [13, 14], versions + + +def test_update_entry_drops_legacy_null_binary(): + manifest = { + "schemaVersion": 2, + "plugins": [ + { + "id": "com.TablePro.DynamoDBDriverPlugin", + "name": "DynamoDB", + "version": "1.0.15", + "summary": "old", + "category": "database-driver", + "binaries": [ + {"architecture": "arm64", "pluginKitVersion": None, "downloadURL": "legacy"}, + ], + } + ], + } + + class Args: + id = "com.TablePro.DynamoDBDriverPlugin" + name = "DynamoDB" + version = "1.0.16" + summary = "new" + db_type_ids = '["dynamodb"]' + arm64_url = "https://x/arm64" + arm64_sha = "a" + x86_64_url = "https://x/x86_64" + x86_64_sha = "b" + min_app_version = "0.43.0" + icon = "icon" + homepage = "https://tablepro.app" + category = "database-driver" + plugin_kit_version = 14 + keep_kit_versions = 2 + + result = update_registry.update_plugin_entry(manifest, Args()) + entry = next(p for p in result["plugins"] if p["id"] == Args.id) + kits = sorted(update_registry.kit_version(b) for b in entry["binaries"]) + assert kits == [14, 14], kits + assert all(update_registry.kit_version(b) is not None for b in entry["binaries"]) + + +if __name__ == "__main__": + test_kit_version_rejects_non_int() + test_prune_drops_null_kit_binary() + test_prune_keeps_only_two_newest() + test_update_entry_drops_legacy_null_binary() + print("All update-registry tests passed.") diff --git a/.github/scripts/update-registry.py b/.github/scripts/update-registry.py index 1990df132..9b1a3391a 100755 --- a/.github/scripts/update-registry.py +++ b/.github/scripts/update-registry.py @@ -65,17 +65,27 @@ def write_manifest_atomic(path, manifest): raise +def kit_version(binary): + value = binary.get("pluginKitVersion") + return value if isinstance(value, int) else None + + def prune_old_kit_versions(binaries, keep_count): + # Drop binaries without a concrete integer PluginKit version. The app matches + # binaries on an exact integer, so a null/missing version can never resolve and + # only shadows valid binaries (the cause of the DynamoDB noCompatibleBinary in #1322). + typed = [b for b in binaries if kit_version(b) is not None] + versions_seen = [] - for binary in binaries: - pkv = binary.get("pluginKitVersion", 0) + for binary in typed: + pkv = kit_version(binary) if pkv not in versions_seen: versions_seen.append(pkv) versions_seen.sort(reverse=True) versions_to_keep = set(versions_seen[:keep_count]) - return [b for b in binaries if b.get("pluginKitVersion", 0) in versions_to_keep] + return [b for b in typed if kit_version(b) in versions_to_keep] def update_plugin_entry(manifest, args): @@ -104,7 +114,7 @@ def update_plugin_entry(manifest, args): if existing_entry is not None: surviving = [ b for b in existing_entry.get("binaries", []) - if b.get("pluginKitVersion", 0) != pkv + if kit_version(b) is not None and kit_version(b) != pkv ] merged_binaries = surviving + new_binaries else: diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cfdd2d7..004004beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Import connections and passwords from DataGrip, including SSH tunnels and SSL settings. The source app doesn't need to be running. (#1374) +### Fixed + +- Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322) +- A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322) + ## [0.43.3] - 2026-05-22 ### Fixed diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index d2941e6a6..589dbc22b 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -33,7 +33,7 @@ enum PluginError: LocalizedError { case .incompatibleVersion(let required, let current): return String(format: String(localized: "Plugin requires PluginKit version %d, but app provides version %d"), required, current) case .pluginOutdated(let pluginVersion, let requiredVersion): - let format = String(localized: "Plugin was built with PluginKit version %d, but version %d is required. Please update the plugin.") + let format = String(localized: "Plugin was built for PluginKit version %d; this release of TablePro needs version %d.") return String(format: format, pluginVersion, requiredVersion) case .cannotUninstallBuiltIn: return String(localized: "Built-in plugins cannot be uninstalled") @@ -62,4 +62,13 @@ enum PluginError: LocalizedError { if case .pluginOutdated = self { return true } return false } + + var isPermanentReconciliationFailure: Bool { + switch self { + case .noCompatibleBinary, .incompatibleVersion, .incompatibleWithCurrentApp, .appVersionTooOld: + return true + default: + return false + } + } } diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index e039a49a2..b8223e0e5 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -24,7 +24,7 @@ extension PluginManager { func runReconciliationLoop() async { let outdated = rejectedPlugins.filter(\.isOutdated) guard !outdated.isEmpty else { - AppEvents.shared.pluginsRejected.send(rejectedPlugins) + emitReconciliationOutcome() refreshRegistryUpdateSet() return } @@ -32,10 +32,19 @@ extension PluginManager { await RegistryClient.shared.fetchManifest() refreshRegistryUpdateSet() guard let manifest = RegistryClient.shared.manifest else { - Self.logger.warning("Reconciliation skipped: registry manifest unavailable") - AppEvents.shared.pluginsRejected.send(rejectedPlugins) + reconciliationManifestAttempts += 1 + guard reconciliationManifestAttempts < ReconciliationConfig.maxAttempts else { + Self.logger.error("Reconciliation gave up: registry manifest unavailable") + emitReconciliationOutcome() + return + } + Self.logger.warning("Reconciliation deferred: registry manifest unavailable, will retry") + scheduleReconciliationRetry() return } + reconciliationManifestAttempts = 0 + + var sawRetryableFailure = false for rejected in outdated { guard !Task.isCancelled else { return } @@ -43,15 +52,12 @@ extension PluginManager { 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()) continue } let attempts = reconciliationAttempts[lookupId, default: 0] - guard attempts < ReconciliationConfig.maxAttempts else { - Self.logger.warning("Reconciliation: max attempts reached for '\(rejected.name)'") - continue - } - + guard attempts < ReconciliationConfig.maxAttempts else { continue } reconciliationAttempts[lookupId] = attempts + 1 do { @@ -67,25 +73,37 @@ extension PluginManager { refreshRegistryUpdateSet() Self.logger.info("Reconciliation: auto-updated '\(rejected.name)'") case .staged: - Self.logger.info("Reconciliation: staged update for '\(rejected.name)' (live connections)") + removeFromRejected(url: rejected.url) + reconciliationAttempts.removeValue(forKey: lookupId) + Self.logger.info("Reconciliation: staged '\(rejected.name)', will activate on disconnect") } + } catch let error as PluginError where error.isPermanentReconciliationFailure { + reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts + updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin)) + Self.logger.error("Reconciliation: no compatible build for '\(rejected.name)'") } catch { - Self.logger.error("Reconciliation: update failed for '\(rejected.name)': \(error.localizedDescription)") + sawRetryableFailure = true + Self.logger.error("Reconciliation: transient failure for '\(rejected.name)': \(error.localizedDescription)") } } - AppEvents.shared.pluginsRejected.send(rejectedPlugins) - scheduleReconciliationRetryIfNeeded(manifest: manifest) + if sawRetryableFailure, hasReconciliationRetryRemaining(manifest: manifest) { + scheduleReconciliationRetry() + return + } + + emitReconciliationOutcome() } - private func scheduleReconciliationRetryIfNeeded(manifest: RegistryManifest) { - let retryable = rejectedPlugins.filter(\.isOutdated).contains { rejected in + private func hasReconciliationRetryRemaining(manifest: RegistryManifest) -> Bool { + rejectedPlugins.filter(\.isOutdated).contains { rejected in guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return false } return reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts } - guard retryable else { return } + } - let round = reconciliationAttempts.values.max() ?? 1 + private func scheduleReconciliationRetry() { + let round = max(reconciliationAttempts.values.max() ?? 0, reconciliationManifestAttempts) let delay = round <= 1 ? ReconciliationConfig.firstRetryDelay : ReconciliationConfig.secondRetryDelay reconciliationTask = Task { [weak self] in try? await Task.sleep(for: delay) @@ -94,6 +112,37 @@ extension PluginManager { } } + private func emitReconciliationOutcome() { + AppEvents.shared.pluginsRejected.send(rejectedPlugins) + } + + private func updateRejectedReason(url: URL, reason: String) { + guard let index = rejectedPlugins.firstIndex(where: { $0.url == url }) else { return } + let existing = rejectedPlugins[index] + rejectedPlugins[index] = RejectedPlugin( + url: existing.url, + bundleId: existing.bundleId, + registryId: existing.registryId, + name: existing.name, + reason: reason, + isOutdated: existing.isOutdated + ) + } + + private func incompatibleBuildReason(for registryPlugin: RegistryPlugin) -> String { + let availableKits = registryPlugin.binaries + .filter { $0.architecture == .current } + .compactMap(\.pluginKitVersion) + if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) { + 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 missingFromRegistryReason() -> String { + String(localized: "This plugin is not in the registry, so it can't be updated automatically.") + } + func resolveRegistryId(for rejected: RejectedPlugin, manifest: RegistryManifest) -> String? { if let id = rejected.registryId { return id } if let bundleId = rejected.bundleId, diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index 51a87c0d1..e53172eee 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -194,11 +194,14 @@ extension PluginManager { 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 { - let finalURL = try PluginInstaller.atomicReplace(stagedBundleURL: url, destURL: destURL) - return try await loadPluginAsync(at: finalURL, source: .userInstalled, replacingBundleId: replaceId) + loadURL = try PluginInstaller.atomicReplace(stagedBundleURL: url, destURL: destURL) + } else { + loadURL = destURL } - return try await loadPluginAsync(at: destURL, source: .userInstalled, replacingBundleId: replaceId) + PluginInstaller.stripQuarantine(at: loadURL) + return try await loadPluginAsync(at: loadURL, source: .userInstalled, replacingBundleId: replaceId) } private func installLocalZip(from url: URL) async throws -> PluginEntry { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index d67096775..864fc0d6f 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -112,6 +112,7 @@ final class PluginManager { @ObservationIgnored internal var reconciliationTask: Task? @ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:] + @ObservationIgnored internal var reconciliationManifestAttempts = 0 @ObservationIgnored private var connectionStatusSubscription: AnyCancellable? @ObservationIgnored internal var installsInFlight: Set = [] diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index f32b3de1a..319af1532 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -82,4 +82,19 @@ struct PluginManagerReconciliationTests { pm.removeFromRejected(url: url) #expect(!pm.rejectedPlugins.contains { $0.url == url }) } + + @Test("incompatible-build errors are permanent reconciliation failures") + func permanentFailuresClassified() { + #expect(PluginError.noCompatibleBinary.isPermanentReconciliationFailure) + #expect(PluginError.incompatibleVersion(required: 15, current: 14).isPermanentReconciliationFailure) + #expect(PluginError.incompatibleWithCurrentApp(minimumRequired: "0.44.0").isPermanentReconciliationFailure) + #expect(PluginError.appVersionTooOld(minimumRequired: "0.44.0", currentApp: "0.43.3").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) + } } diff --git a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift index e96f88040..8c9693a68 100644 --- a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift +++ b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift @@ -52,6 +52,16 @@ struct RegistryBinarySelectionTests { } } + @Test("valid binary is still selected when a nil-kit binary is also present (#1322 DynamoDB)") + func validBinaryFoundAlongsideNilKit() throws { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil), + RegistryBinary(architecture: .arm64, downloadURL: "https://a/14", sha256: "def", pluginKitVersion: 14) + ]) + let resolved = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 14) + #expect(resolved.downloadURL == "https://a/14") + } + @Test("throws noCompatibleBinary when no arch match") func noArchitectureMatch() { let plugin = makePlugin(binaries: [ From d6241f132b778c17f57317e2f1192d8978435692 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 19:37:32 +0700 Subject: [PATCH 2/2] refactor(plugins): make the reconciliation retry decision pure-testable and give exhausted failures a clear reason (#1322) --- .../Plugins/PluginManager+AutoUpdate.swift | 114 +++++++++++------- .../PluginManagerReconciliationTests.swift | 8 ++ 2 files changed, 80 insertions(+), 42 deletions(-) diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index b8223e0e5..d89180ef8 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -35,6 +35,7 @@ extension PluginManager { reconciliationManifestAttempts += 1 guard reconciliationManifestAttempts < ReconciliationConfig.maxAttempts else { Self.logger.error("Reconciliation gave up: registry manifest unavailable") + applyReason(registryUnreachableReason(), to: outdated) emitReconciliationOutcome() return } @@ -44,50 +45,19 @@ extension PluginManager { } reconciliationManifestAttempts = 0 - var sawRetryableFailure = false - + var sawTransientFailure = false + var retryRemaining = false for rejected in outdated { guard !Task.isCancelled else { return } - - 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()) - continue - } - - let attempts = reconciliationAttempts[lookupId, default: 0] - guard attempts < ReconciliationConfig.maxAttempts else { continue } - reconciliationAttempts[lookupId] = attempts + 1 - - do { - let outcome = try await updateFromRegistry( - registryPlugin, - existingPluginLoaded: false, - progress: { _ in } - ) - switch outcome { - case .installed: - removeFromRejected(url: rejected.url) - reconciliationAttempts.removeValue(forKey: lookupId) - refreshRegistryUpdateSet() - Self.logger.info("Reconciliation: auto-updated '\(rejected.name)'") - case .staged: - removeFromRejected(url: rejected.url) - reconciliationAttempts.removeValue(forKey: lookupId) - Self.logger.info("Reconciliation: staged '\(rejected.name)', will activate on disconnect") + if case .transient(let id) = await reconcile(rejected, manifest: manifest) { + sawTransientFailure = true + if reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts { + retryRemaining = true } - } catch let error as PluginError where error.isPermanentReconciliationFailure { - reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts - updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin)) - Self.logger.error("Reconciliation: no compatible build for '\(rejected.name)'") - } catch { - sawRetryableFailure = true - Self.logger.error("Reconciliation: transient failure for '\(rejected.name)': \(error.localizedDescription)") } } - if sawRetryableFailure, hasReconciliationRetryRemaining(manifest: manifest) { + if Self.reconciliationShouldRetry(sawTransientFailure: sawTransientFailure, retryRemaining: retryRemaining) { scheduleReconciliationRetry() return } @@ -95,10 +65,56 @@ extension PluginManager { emitReconciliationOutcome() } - private func hasReconciliationRetryRemaining(manifest: RegistryManifest) -> Bool { - rejectedPlugins.filter(\.isOutdated).contains { rejected in - guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return false } - return reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts + static func reconciliationShouldRetry(sawTransientFailure: Bool, retryRemaining: Bool) -> Bool { + sawTransientFailure && retryRemaining + } + + private enum ReconcileOutcome { + case resolved + case permanent + case missing + case transient(id: String) + } + + 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 + } + + let attempts = reconciliationAttempts[lookupId, default: 0] + guard attempts < ReconciliationConfig.maxAttempts else { return .permanent } + reconciliationAttempts[lookupId] = attempts + 1 + + do { + let outcome = try await updateFromRegistry( + registryPlugin, + existingPluginLoaded: false, + 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") + } + removeFromRejected(url: rejected.url) + reconciliationAttempts.removeValue(forKey: lookupId) + return .resolved + } catch let error as PluginError where error.isPermanentReconciliationFailure { + reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts + updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin)) + Self.logger.error("Reconciliation: no compatible build for '\(rejected.name)'") + 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()) + } + return .transient(id: lookupId) } } @@ -143,6 +159,20 @@ extension PluginManager { String(localized: "This plugin is not in the registry, so it can't be updated automatically.") } + private func registryUnreachableReason() -> String { + String(localized: "TablePro couldn't reach the plugin registry to update this plugin. Check your connection and reopen TablePro.") + } + + private func temporaryFailureReason() -> String { + 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) + } + } + func resolveRegistryId(for rejected: RejectedPlugin, manifest: RegistryManifest) -> String? { if let id = rejected.registryId { return id } if let bundleId = rejected.bundleId, diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index 319af1532..ebd582a14 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -97,4 +97,12 @@ struct PluginManagerReconciliationTests { #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)) + } }