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 3f5f3ee52..c2e0e8ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ 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. +- 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 ab1745b03..50a9dec9b 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -8,9 +8,17 @@ 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 { + case updateAvailable(RegistryPlugin) + case awaitingCompatibleBuild + case requiresAppUpdate + case notInRegistry } extension PluginManager { @@ -108,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)") @@ -123,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 } @@ -216,6 +239,35 @@ extension PluginManager { return manifest.plugins.first(where: { $0.id == id }) } + func rejectedAction(for rejected: RejectedPlugin) -> RejectedPluginAction { + Self.rejectedAction( + registryPlugin: registryPlugin(for: rejected), + manifestLoaded: RegistryClient.shared.manifest != nil, + currentKitVersion: Self.currentPluginKitVersion, + minimumKitVersion: Self.minimumCompatiblePluginKitVersion + ) + } + + static func rejectedAction( + registryPlugin: RegistryPlugin?, + manifestLoaded: Bool, + 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(where: { $0 >= minimumKitVersion && $0 <= currentKitVersion }) { + return .updateAvailable(registryPlugin) + } + if availableKits.contains(where: { $0 > currentKitVersion }) { + return .requiresAppUpdate + } + return .awaitingCompatibleBuild + } + func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool { rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } } @@ -224,8 +276,34 @@ 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 + } + 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 !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() { + 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..244578bf6 --- /dev/null +++ b/TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift @@ -0,0 +1,27 @@ +// +// 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 7ebed4e58..54fdae282 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,8 @@ 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 lastNetworkSatisfied = false @ObservationIgnored internal var installsInFlight: Set = [] var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] @@ -235,6 +239,7 @@ final class PluginManager { self.refreshRegistryUpdateSet() self.subscribeToConnectionStatusChanges() + self.startNetworkReachabilityMonitor() self.scheduleReconciliation() } } @@ -468,7 +473,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/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index ab35d2c75..e7f3b7da9 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -93,14 +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: "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: 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 } @@ -110,17 +109,49 @@ 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, action: actions[plugin.url] ?? .awaitingCompatibleBuild) + Divider().padding(.leading, 12) + } + } } + .frame(maxHeight: Self.rejectedBannerMaxHeight) + + Divider() + } + } + + private static let rejectedBannerMaxHeight: CGFloat = 168 + + private var rejectedActionsByURL: [URL: RejectedPluginAction] { + var result: [URL: RejectedPluginAction] = [:] + for plugin in pluginManager.rejectedPlugins { + result[plugin.url] = pluginManager.rejectedAction(for: plugin) } + return result + } + + private func rejectedBannerTitle(recoverable: Bool) -> String { + let count = pluginManager.rejectedPlugins.count + 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) + } + 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 { + private func rejectedPluginRow(_ plugin: RejectedPlugin, action: RejectedPluginAction) -> 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 +164,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, action: action) Button(String(localized: "Remove")) { removeRejectedPlugin(plugin) } @@ -152,6 +176,32 @@ struct InstalledPluginsView: View { .padding(.vertical, 6) } + @ViewBuilder + private func rejectedActionControl(for plugin: RejectedPlugin, action: RejectedPluginAction) -> some View { + switch action { + 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) + .help(String(localized: "A newer TablePro is required to load this plugin.")) + 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) } 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 d8ffffeaf..057a2b915 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -49,6 +49,117 @@ 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, 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, 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, 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, minimumKitVersion: 17 + ) + #expect(kind(action) == "requiresAppUpdate") + } + + @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, minimumKitVersion: 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 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()