From 78aafc2d934d27d1512b9357b4615c144378a114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 09:18:16 +0700 Subject: [PATCH 1/4] fix(plugins): gate the rejected-plugin Update button and cap the unloaded banner --- CHANGELOG.md | 1 + .../Plugins/PluginManager+AutoUpdate.swift | 22 +++++ .../Plugins/InstalledPluginsView.swift | 82 +++++++++++++++---- 3 files changed, 87 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5f3ee52..d7c97dda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Import now finds the Setapp edition of TablePlus and reads its connections. (#1528) - Favorite keyword suggestions now appear in editor autocomplete. They were dropped before reaching the popup. - Editor autocomplete refreshes when you switch schema, suggesting the new schema's tables and columns. +- Plugins settings: the unloaded-plugins banner now scrolls instead of pushing the plugin list off screen, shows each plugin's real icon, and only offers an Update button when a compatible build exists. Plugins waiting on a build that publishes automatically no longer show a button that fails. ## [0.47.0] - 2026-06-01 diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index ab1745b03..c0aab5069 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -13,6 +13,13 @@ private enum ReconciliationConfig { static let secondRetryDelay: Duration = .seconds(300) } +enum RejectedPluginAction: Sendable { + case updateAvailable(RegistryPlugin) + case awaitingCompatibleBuild + case requiresAppUpdate + case notInRegistry +} + extension PluginManager { func scheduleReconciliation() { reconciliationTask?.cancel() @@ -216,6 +223,21 @@ extension PluginManager { return manifest.plugins.first(where: { $0.id == id }) } + func rejectedAction(for rejected: RejectedPlugin) -> RejectedPluginAction { + guard RegistryClient.shared.manifest != nil else { return .awaitingCompatibleBuild } + guard let registryPlugin = registryPlugin(for: rejected) else { return .notInRegistry } + let availableKits = registryPlugin.binaries + .filter { $0.architecture == .current } + .compactMap(\.pluginKitVersion) + if availableKits.contains(Self.currentPluginKitVersion) { + return .updateAvailable(registryPlugin) + } + if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) { + return .requiresAppUpdate + } + return .awaitingCompatibleBuild + } + func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool { rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index ab35d2c75..c118c97b5 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -95,12 +95,9 @@ struct InstalledPluginsView: View { private var rejectedPluginsBanner: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.red) - Text(pluginManager.rejectedPlugins.count == 1 - ? String(localized: "1 plugin could not be loaded.") - : String(format: String(localized: "%d plugins could not be loaded."), - pluginManager.rejectedPlugins.count)) + Image(systemName: rejectedBannerRecoverable ? "arrow.triangle.2.circlepath" : "exclamationmark.circle.fill") + .foregroundStyle(rejectedBannerRecoverable ? .orange : .red) + Text(rejectedBannerTitle) .font(.callout.weight(.medium)) Spacer() Button(String(localized: "Dismiss")) { dismissedRejectedBanner = true } @@ -110,17 +107,48 @@ struct InstalledPluginsView: View { .padding(.horizontal, 12) .padding(.vertical, 6) - ForEach(pluginManager.rejectedPlugins, id: \.url) { plugin in - rejectedPluginRow(plugin) - Divider().padding(.leading, 12) + Divider() + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(pluginManager.rejectedPlugins, id: \.url) { plugin in + rejectedPluginRow(plugin) + Divider().padding(.leading, 12) + } + } } + .frame(maxHeight: rejectedBannerMaxHeight) + + Divider() + } + } + + private var rejectedBannerMaxHeight: CGFloat { 168 } + + private var rejectedBannerRecoverable: Bool { + pluginManager.rejectedPlugins.allSatisfy { plugin in + if case .notInRegistry = pluginManager.rejectedAction(for: plugin) { return false } + return true } } + private var rejectedBannerTitle: String { + let count = pluginManager.rejectedPlugins.count + if rejectedBannerRecoverable { + return count == 1 + ? String(localized: "1 plugin needs an update to load.") + : String(format: String(localized: "%d plugins need an update to load."), count) + } + return count == 1 + ? String(localized: "1 plugin could not be loaded.") + : String(format: String(localized: "%d plugins could not be loaded."), count) + } + @ViewBuilder private func rejectedPluginRow(_ plugin: RejectedPlugin) -> some View { HStack(spacing: 8) { - Image(systemName: "puzzlepiece") + PluginIconView(name: rejectedIconName(for: plugin)) + .font(.title3) .frame(width: 24, height: 24) .foregroundStyle(.tertiary) VStack(alignment: .leading, spacing: 2) { @@ -133,14 +161,7 @@ struct InstalledPluginsView: View { .lineLimit(2) } Spacer() - if let registryPlugin = registryEntry(for: plugin) { - Button(String(localized: "Update Now")) { - updatePlugin(registryPlugin) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .accessibilityLabel(String(format: String(localized: "Update %@"), plugin.name)) - } + rejectedActionControl(for: plugin) Button(String(localized: "Remove")) { removeRejectedPlugin(plugin) } @@ -152,6 +173,31 @@ struct InstalledPluginsView: View { .padding(.vertical, 6) } + @ViewBuilder + private func rejectedActionControl(for plugin: RejectedPlugin) -> some View { + switch pluginManager.rejectedAction(for: plugin) { + case .updateAvailable(let registryPlugin): + Button(String(localized: "Update Now")) { + updatePlugin(registryPlugin) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityLabel(String(format: String(localized: "Update %@"), plugin.name)) + case .requiresAppUpdate: + Button(String(localized: "Update TablePro")) { + UpdaterBridge.shared.checkForUpdates() + } + .buttonStyle(.bordered) + .controlSize(.small) + case .awaitingCompatibleBuild, .notInRegistry: + EmptyView() + } + } + + private func rejectedIconName(for plugin: RejectedPlugin) -> String { + registryEntry(for: plugin)?.iconName ?? "puzzlepiece" + } + private func registryEntry(for plugin: RejectedPlugin) -> RegistryPlugin? { pluginManager.registryPlugin(for: plugin) } From 9dc57208ce957ad920025ddca1d04bc80096c761 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 09:29:57 +0700 Subject: [PATCH 2/4] refactor(plugins): extract a pure rejected-plugin action resolver and cover it with tests --- .../Plugins/PluginManager+AutoUpdate.swift | 20 ++++-- .../Plugins/InstalledPluginsView.swift | 38 +++++++----- .../PluginManagerReconciliationTests.swift | 62 +++++++++++++++++++ 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index c0aab5069..14f57e93b 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -224,15 +224,27 @@ extension PluginManager { } func rejectedAction(for rejected: RejectedPlugin) -> RejectedPluginAction { - guard RegistryClient.shared.manifest != nil else { return .awaitingCompatibleBuild } - guard let registryPlugin = registryPlugin(for: rejected) else { return .notInRegistry } + Self.rejectedAction( + registryPlugin: registryPlugin(for: rejected), + manifestLoaded: RegistryClient.shared.manifest != nil, + currentKitVersion: Self.currentPluginKitVersion + ) + } + + static func rejectedAction( + registryPlugin: RegistryPlugin?, + manifestLoaded: Bool, + currentKitVersion: Int + ) -> RejectedPluginAction { + guard manifestLoaded else { return .awaitingCompatibleBuild } + guard let registryPlugin else { return .notInRegistry } let availableKits = registryPlugin.binaries .filter { $0.architecture == .current } .compactMap(\.pluginKitVersion) - if availableKits.contains(Self.currentPluginKitVersion) { + if availableKits.contains(currentKitVersion) { return .updateAvailable(registryPlugin) } - if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) { + if availableKits.contains(where: { $0 > currentKitVersion }) { return .requiresAppUpdate } return .awaitingCompatibleBuild diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index c118c97b5..e7f3b7da9 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -93,11 +93,13 @@ struct InstalledPluginsView: View { // MARK: - Rejected Plugins Banner private var rejectedPluginsBanner: some View { - VStack(alignment: .leading, spacing: 0) { + let actions = rejectedActionsByURL + let recoverable = !actions.values.contains { if case .notInRegistry = $0 { return true } else { return false } } + return VStack(alignment: .leading, spacing: 0) { HStack(spacing: 8) { - Image(systemName: rejectedBannerRecoverable ? "arrow.triangle.2.circlepath" : "exclamationmark.circle.fill") - .foregroundStyle(rejectedBannerRecoverable ? .orange : .red) - Text(rejectedBannerTitle) + Image(systemName: recoverable ? "arrow.triangle.2.circlepath" : "exclamationmark.circle.fill") + .foregroundStyle(recoverable ? .orange : .red) + Text(rejectedBannerTitle(recoverable: recoverable)) .font(.callout.weight(.medium)) Spacer() Button(String(localized: "Dismiss")) { dismissedRejectedBanner = true } @@ -112,29 +114,30 @@ struct InstalledPluginsView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(pluginManager.rejectedPlugins, id: \.url) { plugin in - rejectedPluginRow(plugin) + rejectedPluginRow(plugin, action: actions[plugin.url] ?? .awaitingCompatibleBuild) Divider().padding(.leading, 12) } } } - .frame(maxHeight: rejectedBannerMaxHeight) + .frame(maxHeight: Self.rejectedBannerMaxHeight) Divider() } } - private var rejectedBannerMaxHeight: CGFloat { 168 } + private static let rejectedBannerMaxHeight: CGFloat = 168 - private var rejectedBannerRecoverable: Bool { - pluginManager.rejectedPlugins.allSatisfy { plugin in - if case .notInRegistry = pluginManager.rejectedAction(for: plugin) { return false } - return true + private var rejectedActionsByURL: [URL: RejectedPluginAction] { + var result: [URL: RejectedPluginAction] = [:] + for plugin in pluginManager.rejectedPlugins { + result[plugin.url] = pluginManager.rejectedAction(for: plugin) } + return result } - private var rejectedBannerTitle: String { + private func rejectedBannerTitle(recoverable: Bool) -> String { let count = pluginManager.rejectedPlugins.count - if rejectedBannerRecoverable { + if recoverable { return count == 1 ? String(localized: "1 plugin needs an update to load.") : String(format: String(localized: "%d plugins need an update to load."), count) @@ -145,7 +148,7 @@ struct InstalledPluginsView: View { } @ViewBuilder - private func rejectedPluginRow(_ plugin: RejectedPlugin) -> some View { + private func rejectedPluginRow(_ plugin: RejectedPlugin, action: RejectedPluginAction) -> some View { HStack(spacing: 8) { PluginIconView(name: rejectedIconName(for: plugin)) .font(.title3) @@ -161,7 +164,7 @@ struct InstalledPluginsView: View { .lineLimit(2) } Spacer() - rejectedActionControl(for: plugin) + rejectedActionControl(for: plugin, action: action) Button(String(localized: "Remove")) { removeRejectedPlugin(plugin) } @@ -174,8 +177,8 @@ struct InstalledPluginsView: View { } @ViewBuilder - private func rejectedActionControl(for plugin: RejectedPlugin) -> some View { - switch pluginManager.rejectedAction(for: plugin) { + private func rejectedActionControl(for plugin: RejectedPlugin, action: RejectedPluginAction) -> some View { + switch action { case .updateAvailable(let registryPlugin): Button(String(localized: "Update Now")) { updatePlugin(registryPlugin) @@ -189,6 +192,7 @@ struct InstalledPluginsView: View { } .buttonStyle(.bordered) .controlSize(.small) + .help(String(localized: "A newer TablePro is required to load this plugin.")) case .awaitingCompatibleBuild, .notInRegistry: EmptyView() } diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index d8ffffeaf..f61fffa52 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -49,6 +49,68 @@ struct PluginManagerReconciliationTests { ) } + private func makeRegistryPlugin(id: String = "com.example.driver", kitVersions: [Int]) -> RegistryPlugin { + let arch = PluginArchitecture.current.rawValue + let binaries = kitVersions + .map { "{\"architecture\": \"\(arch)\", \"downloadURL\": \"https://x\", \"sha256\": \"deadbeef\", \"pluginKitVersion\": \($0)}" } + .joined(separator: ",") + let json = """ + { + "id": "\(id)", + "name": "Test Plugin", + "version": "1.0.0", + "summary": "test", + "author": {"name": "Tester"}, + "category": "database-driver", + "binaries": [\(binaries)] + } + """ + return try! JSONDecoder().decode(RegistryPlugin.self, from: Data(json.utf8)) + } + + 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() { + let plugin = makeRegistryPlugin(kitVersions: [18]) + let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: false, currentKitVersion: 18) + #expect(kind(action) == "awaitingCompatibleBuild") + } + + @Test("rejectedAction reports notInRegistry when no manifest entry matches") + func rejectedActionNotInRegistry() { + let action = PluginManager.rejectedAction(registryPlugin: nil, manifestLoaded: true, currentKitVersion: 18) + #expect(kind(action) == "notInRegistry") + } + + @Test("rejectedAction offers an update when a current-kit binary exists") + func rejectedActionUpdateAvailable() { + let plugin = makeRegistryPlugin(kitVersions: [17, 18]) + let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18) + #expect(kind(action) == "updateAvailable") + } + + @Test("rejectedAction asks for an app update when only a newer-kit binary exists") + func rejectedActionRequiresAppUpdate() { + let plugin = makeRegistryPlugin(kitVersions: [18, 19]) + let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 17) + #expect(kind(action) == "requiresAppUpdate") + } + + @Test("rejectedAction awaits when only older-kit binaries are published") + func rejectedActionAwaitsForOlderKits() { + let plugin = makeRegistryPlugin(kitVersions: [16, 17]) + let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18) + #expect(kind(action) == "awaitingCompatibleBuild") + } + @Test("resolveRegistryId prefers explicit registryId from sidecar") func resolveRegistryIdUsesRegistryId() { let pm = PluginManager.shared From 697644eeb655b1d4cc59b884faa52558ff7b9c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 10:36:59 +0700 Subject: [PATCH 3/4] fix(plugins): self-heal incompatible drivers at connect instead of blocking (#1552) --- .github/workflows/build-plugin.yml | 3 + .github/workflows/build.yml | 20 ++++- CHANGELOG.md | 1 + TablePro/Core/Database/DatabaseDriver.swift | 14 +++- TablePro/Core/Plugins/PluginInstaller.swift | 5 +- .../Plugins/PluginManager+AutoUpdate.swift | 55 ++++++++++--- .../Core/Plugins/PluginManager+Install.swift | 4 +- .../PluginManager+NetworkMonitor.swift | 21 +++++ TablePro/Core/Plugins/PluginManager.swift | 6 +- .../Plugins/Registry/RegistryClient.swift | 2 +- .../Plugins/Registry/RegistryModels.swift | 15 +++- .../Plugins/PluginInstallerHelpersTests.swift | 4 +- .../PluginManagerReconciliationTests.swift | 31 ++++++-- .../RegistryBinarySelectionTests.swift | 50 ++++++++++-- docs/development/plugin-registry.mdx | 22 ++++++ scripts/check-registry-readiness.py | 77 +++++++++++++++++++ 16 files changed, 296 insertions(+), 34 deletions(-) create mode 100644 TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift create mode 100644 scripts/check-registry-readiness.py diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 33b0cdfdb..ffecdef41 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -425,6 +425,9 @@ jobs: if git push; then echo "Registry updated on attempt $attempt" + curl -sf "https://purge.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json" >/dev/null \ + && echo "Purged jsDelivr cache for plugins.json" \ + || echo "::warning::jsDelivr purge request failed; the cache will refresh on its own shortly" break fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a71f23240..e0d8cffe2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -316,10 +316,28 @@ jobs: build/Release/TablePro-*.dmg build/Release/TablePro-*.zip + registry-readiness: + name: Registry Readiness + runs-on: macos-26 + if: startsWith(github.ref, 'refs/tags/v') + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify registry has compatible plugin binaries + run: | + MANAGER="TablePro/Core/Plugins/PluginManager.swift" + CURRENT=$(grep -E 'static let currentPluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1) + FLOOR=$(grep -E 'static let minimumCompatiblePluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1) + echo "PluginKit floor=$FLOOR current=$CURRENT" + python3 scripts/check-registry-readiness.py --floor "$FLOOR" --current "$CURRENT" + release: name: Create GitHub Release runs-on: macos-26 - needs: [lint, test, build-arm64, build-x86_64] + needs: [lint, test, build-arm64, build-x86_64, registry-readiness] if: startsWith(github.ref, 'refs/tags/v') timeout-minutes: 10 permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c97dda0..c2e0e8ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Favorite keyword suggestions now appear in editor autocomplete. They were dropped before reaching the popup. - Editor autocomplete refreshes when you switch schema, suggesting the new schema's tables and columns. - Plugins settings: the unloaded-plugins banner now scrolls instead of pushing the plugin list off screen, shows each plugin's real icon, and only offers an Update button when a compatible build exists. Plugins waiting on a build that publishes automatically no longer show a button that fails. +- Opening a connection right after an app update no longer fails when its driver plugin needs updating. The driver updates in the background and the connection proceeds, instead of showing an error until you quit and reopen. (#1552) ## [0.47.0] - 2026-06-01 diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index bcdb23c1e..e2a051875 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -408,8 +408,18 @@ enum DatabaseDriverFactory { } if PluginManager.shared.driverPlugin(for: connection.type) == nil, PluginManager.shared.hasOutdatedRejectedPlugin(forTypeId: pluginId) { - logger.info("Plugin '\(pluginId)' is installed but outdated, waiting for reconciliation to update it") - await PluginManager.shared.awaitReconciliation() + logger.info("Plugin '\(pluginId)' is installed but outdated, updating it before connect") + await PluginManager.shared.ensurePluginReady(forTypeId: pluginId) + } + if PluginManager.shared.driverPlugin(for: connection.type) == nil, + connection.type.isDownloadablePlugin, + !PluginManager.shared.hasOutdatedRejectedPlugin(forTypeId: pluginId) { + logger.info("Plugin '\(pluginId)' not installed, installing on demand before connect") + do { + try await PluginManager.shared.installMissingPlugin(for: connection.type) { _ in } + } catch { + logger.warning("On-demand install for '\(pluginId)' did not complete: \(error.localizedDescription)") + } } return try await createDriverFromPlugin(for: connection, passwordOverride: passwordOverride) } diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index 981f0054e..6a146b3cd 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -175,6 +175,7 @@ actor PluginInstaller { let context = await MainActor.run { ( kit: PluginManager.currentPluginKitVersion, + minimumKit: PluginManager.minimumCompatiblePluginKitVersion, inspector: PluginManager.currentInspectorKitVersion, session: RegistryClient.shared.session ) @@ -218,6 +219,7 @@ actor PluginInstaller { try Self.validateStagedABI( bundleURL: bundleURL, currentKit: context.kit, + minimumKit: context.minimumKit, currentInspector: context.inspector ) Self.stripQuarantine(at: bundleURL) @@ -272,6 +274,7 @@ actor PluginInstaller { nonisolated static func validateStagedABI( bundleURL: URL, currentKit: Int, + minimumKit: Int, currentInspector: Int ) throws { guard let bundle = Bundle(url: bundleURL), @@ -288,7 +291,7 @@ actor PluginInstaller { if version > currentKit { throw PluginError.incompatibleVersion(required: version, current: currentKit) } - if version < currentKit { + if version < minimumKit { throw PluginError.pluginOutdated(pluginVersion: version, requiredVersion: currentKit) } } diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 14f57e93b..aea16e5d0 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -8,9 +8,10 @@ import Foundation import os private enum ReconciliationConfig { - static let maxAttempts = 3 + 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 { @@ -115,9 +116,19 @@ extension PluginManager { reconciliationAttempts.removeValue(forKey: lookupId) return .resolved } catch let error as PluginError where error.isPermanentReconciliationFailure { - reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts + let action = Self.rejectedAction( + registryPlugin: registryPlugin, + manifestLoaded: true, + currentKitVersion: Self.currentPluginKitVersion, + minimumKitVersion: Self.minimumCompatiblePluginKitVersion + ) updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin)) - Self.logger.error("Reconciliation: no compatible build for '\(rejected.name)'") + if case .noCompatibleBinary = error, case .awaitingCompatibleBuild = action { + Self.logger.warning("Reconciliation: no compatible build published yet for '\(rejected.name)', will retry") + return .transient(id: lookupId) + } + 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)") @@ -130,7 +141,12 @@ extension PluginManager { private func scheduleReconciliationRetry() { let round = max(reconciliationAttempts.values.max() ?? 0, reconciliationManifestAttempts) - let delay = round <= 1 ? ReconciliationConfig.firstRetryDelay : ReconciliationConfig.secondRetryDelay + let delay: Duration + switch round { + case ..<2: delay = ReconciliationConfig.firstRetryDelay + case 2: delay = ReconciliationConfig.secondRetryDelay + default: delay = ReconciliationConfig.thirdRetryDelay + } reconciliationTask = Task { [weak self] in try? await Task.sleep(for: delay) guard !Task.isCancelled else { return } @@ -227,21 +243,23 @@ extension PluginManager { Self.rejectedAction( registryPlugin: registryPlugin(for: rejected), manifestLoaded: RegistryClient.shared.manifest != nil, - currentKitVersion: Self.currentPluginKitVersion + currentKitVersion: Self.currentPluginKitVersion, + minimumKitVersion: Self.minimumCompatiblePluginKitVersion ) } static func rejectedAction( registryPlugin: RegistryPlugin?, manifestLoaded: Bool, - currentKitVersion: Int + currentKitVersion: Int, + minimumKitVersion: Int ) -> RejectedPluginAction { guard manifestLoaded else { return .awaitingCompatibleBuild } guard let registryPlugin else { return .notInRegistry } let availableKits = registryPlugin.binaries .filter { $0.architecture == .current } .compactMap(\.pluginKitVersion) - if availableKits.contains(currentKitVersion) { + if availableKits.contains(where: { $0 >= minimumKitVersion && $0 <= currentKitVersion }) { return .updateAvailable(registryPlugin) } if availableKits.contains(where: { $0 > currentKitVersion }) { @@ -258,8 +276,25 @@ extension PluginManager { rejectedPlugins.first { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }?.reason } - func awaitReconciliation() async { - guard reconciliationActive, let task = reconciliationTask else { return } - await task.value + func ensurePluginReady(forTypeId typeId: String) async { + if reconciliationActive, let task = reconciliationTask { + await task.value + return + } + guard hasOutdatedRejectedPlugin(forTypeId: typeId) else { return } + reconciliationAttempts.removeAll() + reconciliationManifestAttempts = 0 + scheduleReconciliation() + if let task = reconciliationTask { + await task.value + } + } + + func retriggerReconciliation() { + guard !reconciliationActive else { return } + guard rejectedPlugins.contains(where: \.isOutdated) else { return } + reconciliationAttempts.removeAll() + reconciliationManifestAttempts = 0 + scheduleReconciliation() } } diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index ceed20ad1..b0f2eb426 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -167,7 +167,8 @@ extension PluginManager { } return try registryPlugin.resolvedBinary( for: .current, - pluginKitVersion: Self.currentPluginKitVersion + currentKitVersion: Self.currentPluginKitVersion, + minimumKitVersion: Self.minimumCompatiblePluginKitVersion ) } @@ -226,6 +227,7 @@ extension PluginManager { try PluginInstaller.validateStagedABI( bundleURL: bundleURL, currentKit: Self.currentPluginKitVersion, + minimumKit: Self.minimumCompatiblePluginKitVersion, currentInspector: Self.currentInspectorKitVersion ) PluginInstaller.stripQuarantine(at: bundleURL) diff --git a/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift b/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift new file mode 100644 index 000000000..bac840b06 --- /dev/null +++ b/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift @@ -0,0 +1,21 @@ +// +// 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 + guard path.status == .satisfied else { return } + Task { @MainActor [weak self] in + self?.retriggerReconciliation() + } + } + monitor.start(queue: DispatchQueue(label: "com.TablePro.pluginNetworkMonitor")) + } +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 7ebed4e58..be0ef59a7 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -5,6 +5,7 @@ import Combine import Foundation +import Network import os import Security import SwiftUI @@ -14,6 +15,7 @@ import TableProPluginKit final class PluginManager { static let shared = PluginManager() static let currentPluginKitVersion = 18 + static let minimumCompatiblePluginKitVersion = 18 static let currentInspectorKitVersion = 1 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" @@ -115,6 +117,7 @@ final class PluginManager { @ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:] @ObservationIgnored internal var reconciliationManifestAttempts = 0 @ObservationIgnored private var connectionStatusSubscription: AnyCancellable? + @ObservationIgnored internal var pluginNetworkMonitor: NWPathMonitor? @ObservationIgnored internal var installsInFlight: Set = [] var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] @@ -235,6 +238,7 @@ final class PluginManager { self.refreshRegistryUpdateSet() self.subscribeToConnectionStatusChanges() + self.startNetworkReachabilityMonitor() self.scheduleReconciliation() } } @@ -468,7 +472,7 @@ final class PluginManager { current: currentPluginKitVersion ) } - if version < currentPluginKitVersion { + if version < minimumCompatiblePluginKitVersion { throw PluginError.pluginOutdated( pluginVersion: version, requiredVersion: currentPluginKitVersion diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index 0969e49a2..3c2e0e517 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -24,7 +24,7 @@ final class RegistryClient { private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient") private static let defaultRegistryURL = URL(string: - "https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")! // swiftlint:disable:this force_unwrapping + "https://cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json")! // swiftlint:disable:this force_unwrapping static let customRegistryURLKey = "com.TablePro.customRegistryURL" private static let lastRegistryURLKey = "com.TablePro.lastRegistryURL" diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index 8d9784dd2..35f516106 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -105,12 +105,19 @@ struct RegistryPlugin: Codable, Sendable, Identifiable { extension RegistryPlugin { func resolvedBinary( for arch: PluginArchitecture = .current, - pluginKitVersion: Int + currentKitVersion: Int, + minimumKitVersion: Int ) throws -> RegistryBinary { - let archMatches = binaries.filter { $0.architecture == arch } + let compatible = binaries + .filter { $0.architecture == arch } + .filter { binary in + guard let kit = binary.pluginKitVersion else { return false } + return kit >= minimumKitVersion && kit <= currentKitVersion + } + .max { ($0.pluginKitVersion ?? 0) < ($1.pluginKitVersion ?? 0) } - if let exact = archMatches.first(where: { $0.pluginKitVersion == pluginKitVersion }) { - return exact + if let compatible { + return compatible } throw PluginError.noCompatibleBinary diff --git a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift index 6d9ce34dd..3852d93f0 100644 --- a/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift +++ b/TableProTests/Core/Plugins/PluginInstallerHelpersTests.swift @@ -102,7 +102,7 @@ struct PluginInstallerHelpersTests { try emptyPlist.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8) #expect(throws: PluginError.self) { - try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, currentInspector: 1) + try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, minimumKit: 13, currentInspector: 1) } } @@ -112,7 +112,7 @@ struct PluginInstallerHelpersTests { defer { try? FileManager.default.removeItem(at: dir) } let bundle = try makeFakeBundle(at: dir, name: "Driver") - try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, currentInspector: 1) + try PluginInstaller.validateStagedABI(bundleURL: bundle, currentKit: 13, minimumKit: 13, currentInspector: 1) } @Test("findBundle returns the single .tableplugin in a directory") diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index f61fffa52..77aebcea9 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -80,34 +80,53 @@ struct PluginManagerReconciliationTests { @Test("rejectedAction awaits while the manifest is still loading") func rejectedActionAwaitsWithoutManifest() { let plugin = makeRegistryPlugin(kitVersions: [18]) - let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: false, currentKitVersion: 18) + let action = PluginManager.rejectedAction( + registryPlugin: plugin, manifestLoaded: false, currentKitVersion: 18, minimumKitVersion: 18 + ) #expect(kind(action) == "awaitingCompatibleBuild") } @Test("rejectedAction reports notInRegistry when no manifest entry matches") func rejectedActionNotInRegistry() { - let action = PluginManager.rejectedAction(registryPlugin: nil, manifestLoaded: true, currentKitVersion: 18) + let action = PluginManager.rejectedAction( + registryPlugin: nil, manifestLoaded: true, currentKitVersion: 18, minimumKitVersion: 18 + ) #expect(kind(action) == "notInRegistry") } @Test("rejectedAction offers an update when a current-kit binary exists") func rejectedActionUpdateAvailable() { let plugin = makeRegistryPlugin(kitVersions: [17, 18]) - let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18) + let action = PluginManager.rejectedAction( + registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18, minimumKitVersion: 18 + ) + #expect(kind(action) == "updateAvailable") + } + + @Test("rejectedAction offers an update for a resilient older-kit binary under a newer app") + func rejectedActionUpdateAvailableForwardCompat() { + let plugin = makeRegistryPlugin(kitVersions: [18]) + let action = PluginManager.rejectedAction( + registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 19, minimumKitVersion: 18 + ) #expect(kind(action) == "updateAvailable") } @Test("rejectedAction asks for an app update when only a newer-kit binary exists") func rejectedActionRequiresAppUpdate() { let plugin = makeRegistryPlugin(kitVersions: [18, 19]) - let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 17) + let action = PluginManager.rejectedAction( + registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 17, minimumKitVersion: 17 + ) #expect(kind(action) == "requiresAppUpdate") } - @Test("rejectedAction awaits when only older-kit binaries are published") + @Test("rejectedAction awaits when only pre-floor binaries are published") func rejectedActionAwaitsForOlderKits() { let plugin = makeRegistryPlugin(kitVersions: [16, 17]) - let action = PluginManager.rejectedAction(registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18) + let action = PluginManager.rejectedAction( + registryPlugin: plugin, manifestLoaded: true, currentKitVersion: 18, minimumKitVersion: 18 + ) #expect(kind(action) == "awaitingCompatibleBuild") } diff --git a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift index 8c9693a68..b9b1aa16c 100644 --- a/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift +++ b/TableProTests/Core/Plugins/RegistryBinarySelectionTests.swift @@ -38,7 +38,7 @@ struct RegistryBinarySelectionTests { RegistryBinary(architecture: .arm64, downloadURL: "https://a/13", sha256: "aaa", pluginKitVersion: 13), RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) ]) - let resolved = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + let resolved = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 13, minimumKitVersion: 13) #expect(resolved.downloadURL == "https://a/13") } @@ -48,7 +48,7 @@ struct RegistryBinarySelectionTests { RegistryBinary(architecture: .arm64, downloadURL: "https://legacy", sha256: "abc", pluginKitVersion: nil) ]) #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + _ = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 13, minimumKitVersion: 13) } } @@ -58,17 +58,57 @@ struct RegistryBinarySelectionTests { 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) + let resolved = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 14, minimumKitVersion: 14) #expect(resolved.downloadURL == "https://a/14") } + @Test("resilient older-kit binary is served to a newer app without re-publish") + func forwardCompatibleBinarySelected() throws { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://a/18", sha256: "aaa", pluginKitVersion: 18) + ]) + let resolved = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 19, minimumKitVersion: 18) + #expect(resolved.downloadURL == "https://a/18") + } + + @Test("highest binary within the compatible range wins") + func highestInRangeSelected() throws { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://a/18", sha256: "aaa", pluginKitVersion: 18), + RegistryBinary(architecture: .arm64, downloadURL: "https://a/19", sha256: "bbb", pluginKitVersion: 19), + RegistryBinary(architecture: .arm64, downloadURL: "https://a/20", sha256: "ccc", pluginKitVersion: 20) + ]) + let resolved = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 20, minimumKitVersion: 18) + #expect(resolved.downloadURL == "https://a/20") + } + + @Test("binary below the floor is rejected") + func belowFloorRejected() { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://a/17", sha256: "aaa", pluginKitVersion: 17) + ]) + #expect(throws: PluginError.self) { + _ = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 18, minimumKitVersion: 18) + } + } + + @Test("binary built for a newer app is rejected") + func aboveCurrentRejected() { + let plugin = makePlugin(binaries: [ + RegistryBinary(architecture: .arm64, downloadURL: "https://a/19", sha256: "aaa", pluginKitVersion: 19) + ]) + #expect(throws: PluginError.self) { + _ = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 18, minimumKitVersion: 18) + } + } + @Test("throws noCompatibleBinary when no arch match") func noArchitectureMatch() { let plugin = makePlugin(binaries: [ RegistryBinary(architecture: .x86_64, downloadURL: "https://intel", sha256: "x", pluginKitVersion: 13) ]) #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + _ = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 13, minimumKitVersion: 13) } } @@ -78,7 +118,7 @@ struct RegistryBinarySelectionTests { RegistryBinary(architecture: .arm64, downloadURL: "https://a/12", sha256: "bbb", pluginKitVersion: 12) ]) #expect(throws: PluginError.self) { - _ = try plugin.resolvedBinary(for: .arm64, pluginKitVersion: 13) + _ = try plugin.resolvedBinary(for: .arm64, currentKitVersion: 13, minimumKitVersion: 13) } } diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index 79eb711cf..84025fa8e 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -103,6 +103,28 @@ CI builds both architectures, signs the bundles, creates a GitHub release, and u +## Plugin Compatibility + +Every plugin binary declares the PluginKit version it was built against (`pluginKitVersion`). The app declares two values in `PluginManager.swift`: + +- `currentPluginKitVersion`: the version the running app ships. +- `minimumCompatiblePluginKitVersion`: the oldest version the app still loads. + +TableProPluginKit is built with Swift Library Evolution, so a plugin built against any version in `[minimum, current]` loads under the app and the runtime fills in any newer requirement from its default. The app accepts that whole range instead of an exact match. + +When you change PluginKit: + +- **Additive change** (a new requirement with a default, a new field on a non-frozen type): leave both versions alone. Installed plugins keep loading; the registry needs no re-publish. +- **Breaking change** (a removed or changed requirement, a frozen-layout change, a requirement without a default): raise `currentPluginKitVersion` and `minimumCompatiblePluginKitVersion` together, then run `scripts/release-all-plugins.sh ` so the registry carries binaries for the new version before the app ships. + +The registry serves the newest binary whose `pluginKitVersion` is in the app's range, so an additive bump is served by the existing binaries with no re-publish. After a breaking bump, `scripts/check-registry-readiness.py --floor --current ` (run by the app release workflow) fails the release until every database driver has a compatible binary, so the app never ships ahead of its plugins. + +If a connection's driver is installed but its binary predates a breaking bump, opening the connection refreshes the registry and updates the driver in the background, then proceeds. The app does not block on this; if a compatible binary is not published yet, the connection reports that it is updating and retries on its own (on a timer and when the network returns), so no quit and reopen is needed. + +The manifest is served through jsDelivr (`cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json`) and purged on each publish, so a new binary is visible within seconds rather than waiting out a CDN cache. + +Drivers that most users reach for are bundled inside the app and ship in lockstep, so they never depend on the registry. Reserve registry-only distribution for the long tail, where a short, non-blocking update on first connect is acceptable. + ## Theme Distribution Themes use the same manifest format with `category: "theme"`. Key differences from driver plugins: diff --git a/scripts/check-registry-readiness.py b/scripts/check-registry-readiness.py new file mode 100644 index 000000000..0f19f787a --- /dev/null +++ b/scripts/check-registry-readiness.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Fail if any registry database driver lacks a binary compatible with the app's +PluginKit range [floor, current]. + +Run this before tagging an app release so the app can never ship ahead of its +plugin binaries. With range-aware binary selection an additive PluginKit bump +needs no re-publish (an older resilient binary still serves), so this only fails +after a breaking bump that raised the floor and left the registry behind. +""" + +import argparse +import json +import sys +import urllib.request + +DEFAULT_MANIFEST_URL = "https://cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json" + + +def fetch_manifest(url, retries=4): + last_error = None + for attempt in range(retries): + try: + request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"}) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except Exception as error: # noqa: BLE001 - surface any fetch/parse failure + last_error = error + raise SystemExit(f"ERROR: could not fetch registry manifest from {url}: {last_error}") + + +def compatible_kits(plugin): + return sorted({ + binary.get("pluginKitVersion") + for binary in plugin.get("binaries", []) + if binary.get("pluginKitVersion") is not None + }) + + +def has_compatible_binary(plugin, floor, current): + return any(floor <= kit <= current for kit in compatible_kits(plugin)) + + +def main(): + parser = argparse.ArgumentParser(description="Check plugin registry readiness for an app release") + parser.add_argument("--manifest-url", default=DEFAULT_MANIFEST_URL) + parser.add_argument("--floor", required=True, type=int, help="minimumCompatiblePluginKitVersion") + parser.add_argument("--current", required=True, type=int, help="currentPluginKitVersion") + args = parser.parse_args() + + manifest = fetch_manifest(args.manifest_url) + plugins = manifest.get("plugins", manifest if isinstance(manifest, list) else []) + drivers = [plugin for plugin in plugins if plugin.get("category") == "database-driver"] + + if not drivers: + raise SystemExit("ERROR: no database-driver plugins found in the registry manifest") + + not_ready = [] + for plugin in drivers: + if not has_compatible_binary(plugin, args.floor, args.current): + name = plugin.get("name") or plugin.get("id") or "?" + not_ready.append(f"{name}: no binary in [{args.floor},{args.current}] (has {compatible_kits(plugin)})") + + if not_ready: + print("Registry is NOT ready for this app release:", file=sys.stderr) + for entry in not_ready: + print(f" - {entry}", file=sys.stderr) + print( + "Run scripts/release-all-plugins.sh for the new PluginKit version before tagging the app.", + file=sys.stderr, + ) + sys.exit(1) + + print(f"Registry ready: all {len(drivers)} database drivers have a binary in [{args.floor},{args.current}].") + + +if __name__ == "__main__": + main() From 385412037ade028a6763563edcd981e8f8be68a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Tue, 2 Jun 2026 12:37:28 +0700 Subject: [PATCH 4/4] perf(plugins): reconcile only the connecting driver at connect, debounce network retriggers --- .../Plugins/PluginManager+AutoUpdate.swift | 21 +++++++++---- .../PluginManager+NetworkMonitor.swift | 10 +++++-- TablePro/Core/Plugins/PluginManager.swift | 1 + .../PluginManagerReconciliationTests.swift | 30 +++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index aea16e5d0..50a9dec9b 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -279,15 +279,24 @@ extension PluginManager { func ensurePluginReady(forTypeId typeId: String) async { if reconciliationActive, let task = reconciliationTask { await task.value - return } guard hasOutdatedRejectedPlugin(forTypeId: typeId) else { return } - reconciliationAttempts.removeAll() - reconciliationManifestAttempts = 0 - scheduleReconciliation() - if let task = reconciliationTask { - await task.value + await reconcileOutdated(matchingTypeId: typeId) + } + + private func reconcileOutdated(matchingTypeId typeId: String) async { + let targets = rejectedPlugins.filter { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } + guard !targets.isEmpty else { return } + await RegistryClient.shared.fetchManifest(forceRefresh: true) + 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() } func retriggerReconciliation() { diff --git a/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift b/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift index bac840b06..244578bf6 100644 --- a/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift +++ b/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift @@ -11,11 +11,17 @@ extension PluginManager { let monitor = NWPathMonitor() pluginNetworkMonitor = monitor monitor.pathUpdateHandler = { [weak self] path in - guard path.status == .satisfied else { return } + let satisfied = path.status == .satisfied Task { @MainActor [weak self] in - self?.retriggerReconciliation() + 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 be0ef59a7..54fdae282 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -118,6 +118,7 @@ final class PluginManager { @ObservationIgnored internal var reconciliationManifestAttempts = 0 @ObservationIgnored private var connectionStatusSubscription: AnyCancellable? @ObservationIgnored internal var pluginNetworkMonitor: NWPathMonitor? + @ObservationIgnored internal var lastNetworkSatisfied = false @ObservationIgnored internal var installsInFlight: Set = [] var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index 77aebcea9..057a2b915 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -130,6 +130,36 @@ struct PluginManagerReconciliationTests { #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