From 04b42cb30cee830389999a647593b5e0f3fa7816 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: Thu, 28 May 2026 08:14:07 +0700 Subject: [PATCH 1/2] fix(plugins): wait for reconciliation before reporting a driver missing on connect (#1380) --- CHANGELOG.md | 1 + TablePro/Core/Database/DatabaseDriver.swift | 10 ++++- TablePro/Core/Plugins/PluginError.swift | 3 ++ .../Plugins/PluginManager+AutoUpdate.swift | 18 +++++++- TablePro/Core/Plugins/PluginManager.swift | 10 +++-- TablePro/Core/Plugins/PluginModels.swift | 1 + .../PluginManagerReconciliationTests.swift | 41 +++++++++++++++++-- 7 files changed, 76 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c973fcbd4..0b53c3990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400) - MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418) +- After updating TablePro, connecting to a database whose plugin is still updating in the background now waits for that update to finish instead of wrongly showing "Plugin Not Installed", so you no longer have to quit and reopen the app. When no compatible plugin build exists yet, the message now tells you to update TablePro. (#1380) ## [0.45.0] - 2026-05-26 diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index c13b62485..3181e4892 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -403,9 +403,14 @@ enum DatabaseDriverFactory { let pluginId = connection.type.pluginTypeId if PluginManager.shared.driverPlugin(for: connection.type) == nil, !PluginManager.shared.hasFinishedInitialLoad { - logger.info("Plugin '\(pluginId)' not loaded yet — waiting for background load") + logger.info("Plugin '\(pluginId)' not loaded yet, waiting for background load") await PluginManager.shared.waitForInitialLoad() } + 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() + } return try await createDriverFromPlugin(for: connection, passwordOverride: passwordOverride) } @@ -415,6 +420,9 @@ enum DatabaseDriverFactory { ) async throws -> DatabaseDriver { let pluginId = connection.type.pluginTypeId guard let plugin = PluginManager.shared.driverPlugin(for: connection.type) else { + if let reason = PluginManager.shared.outdatedReconcileReason(forTypeId: pluginId) { + throw PluginError.pluginUpdateUnavailable(reason: reason) + } if connection.type.isDownloadablePlugin { throw PluginError.pluginNotInstalled(connection.type.rawValue) } diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index 589dbc22b..80c65280d 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -19,6 +19,7 @@ enum PluginError: LocalizedError { case appVersionTooOld(minimumRequired: String, currentApp: String) case downloadFailed(String) case pluginNotInstalled(String) + case pluginUpdateUnavailable(reason: String) case incompatibleWithCurrentApp(minimumRequired: String) case invalidDescriptor(pluginId: String, reason: String) @@ -51,6 +52,8 @@ enum PluginError: LocalizedError { return String(format: String(localized: "Plugin download failed: %@"), reason) case .pluginNotInstalled(let databaseType): return String(format: String(localized: "The %@ plugin is not installed. You can download it from the plugin marketplace."), databaseType) + case .pluginUpdateUnavailable(let reason): + return reason case .incompatibleWithCurrentApp(let minimumRequired): return String(format: String(localized: "This plugin requires TablePro %@ or later"), minimumRequired) case .invalidDescriptor(let pluginId, let reason): diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index c87632a61..ab1745b03 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -16,12 +16,14 @@ private enum ReconciliationConfig { extension PluginManager { func scheduleReconciliation() { reconciliationTask?.cancel() + reconciliationActive = true reconciliationTask = Task { [weak self] in await self?.runReconciliationLoop() } } func runReconciliationLoop() async { + defer { reconciliationActive = false } let outdated = rejectedPlugins.filter(\.isOutdated) guard !outdated.isEmpty else { emitReconciliationOutcome() @@ -142,7 +144,8 @@ extension PluginManager { registryId: existing.registryId, name: existing.name, reason: reason, - isOutdated: existing.isOutdated + isOutdated: existing.isOutdated, + providedDatabaseTypeIds: existing.providedDatabaseTypeIds ) } @@ -212,4 +215,17 @@ extension PluginManager { guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return nil } return manifest.plugins.first(where: { $0.id == id }) } + + func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool { + rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) } + } + + func outdatedReconcileReason(forTypeId typeId: String) -> String? { + rejectedPlugins.first { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }?.reason + } + + func awaitReconciliation() async { + guard reconciliationActive, let task = reconciliationTask else { return } + await task.value + } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 44c40bf7d..d6b49ad41 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -111,6 +111,7 @@ final class PluginManager { @ObservationIgnored private var activatedBundleIds: Set = [] @ObservationIgnored internal var reconciliationTask: Task? + @ObservationIgnored internal var reconciliationActive = false @ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:] @ObservationIgnored internal var reconciliationManifestAttempts = 0 @ObservationIgnored private var connectionStatusSubscription: AnyCancellable? @@ -291,7 +292,8 @@ final class PluginManager { registryId: Self.readRegistryMetadata(for: url)?.pluginId, name: manifest.bundleId, reason: error.localizedDescription, - isOutdated: (error as? PluginError)?.isOutdated ?? false + isOutdated: (error as? PluginError)?.isOutdated ?? false, + providedDatabaseTypeIds: manifest.providedDatabaseTypeIds )) } return @@ -307,7 +309,8 @@ final class PluginManager { registryId: Self.readRegistryMetadata(for: url)?.pluginId, name: manifest.bundleId, reason: error.localizedDescription, - isOutdated: false + isOutdated: false, + providedDatabaseTypeIds: manifest.providedDatabaseTypeIds )) return } @@ -620,7 +623,8 @@ final class PluginManager { registryId: Self.readRegistryMetadata(for: winner.url)?.pluginId, name: winner.url.deletingPathExtension().lastPathComponent, reason: error.localizedDescription, - isOutdated: (error as? PluginError)?.isOutdated ?? false + isOutdated: (error as? PluginError)?.isOutdated ?? false, + providedDatabaseTypeIds: bundle.flatMap { PluginManifest(bundle: $0)?.providedDatabaseTypeIds } ?? [] )) } } diff --git a/TablePro/Core/Plugins/PluginModels.swift b/TablePro/Core/Plugins/PluginModels.swift index 0bd355a9b..88d657094 100644 --- a/TablePro/Core/Plugins/PluginModels.swift +++ b/TablePro/Core/Plugins/PluginModels.swift @@ -39,6 +39,7 @@ struct RejectedPlugin: Sendable { let name: String let reason: String let isOutdated: Bool + let providedDatabaseTypeIds: [String] } extension PluginEntry { diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index 5f3c4b148..d8ffffeaf 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -34,15 +34,18 @@ struct PluginManagerReconciliationTests { private func makeRejected( bundleId: String? = nil, registryId: String? = nil, - isOutdated: Bool = true + isOutdated: Bool = true, + reason: String = "ABI mismatch", + providedDatabaseTypeIds: [String] = [] ) -> RejectedPlugin { RejectedPlugin( url: URL(fileURLWithPath: "/tmp/test-\(UUID().uuidString).tableplugin"), bundleId: bundleId, registryId: registryId, name: "Test", - reason: "ABI mismatch", - isOutdated: isOutdated + reason: reason, + isOutdated: isOutdated, + providedDatabaseTypeIds: providedDatabaseTypeIds ) } @@ -83,6 +86,38 @@ struct PluginManagerReconciliationTests { #expect(!pm.rejectedPlugins.contains { $0.url == url }) } + @Test("connect treats an outdated installed plugin as updatable, not missing") + func hasOutdatedRejectedPluginMatchesType() { + let pm = PluginManager.shared + let rejected = makeRejected(bundleId: "com.example.driver", providedDatabaseTypeIds: ["TestDriverType"]) + pm.rejectedPlugins.append(rejected) + defer { pm.removeFromRejected(url: rejected.url) } + #expect(pm.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) + #expect(!pm.hasOutdatedRejectedPlugin(forTypeId: "OtherDriverType")) + } + + @Test("plugins rejected for non-ABI reasons are not treated as updatable") + func hasOutdatedRejectedPluginIgnoresNonOutdated() { + let pm = PluginManager.shared + let rejected = makeRejected(isOutdated: false, providedDatabaseTypeIds: ["TestDriverType"]) + pm.rejectedPlugins.append(rejected) + defer { pm.removeFromRejected(url: rejected.url) } + #expect(!pm.hasOutdatedRejectedPlugin(forTypeId: "TestDriverType")) + } + + @Test("outdatedReconcileReason surfaces the rejected plugin's reason for the type") + func outdatedReconcileReasonReturnsReason() { + let pm = PluginManager.shared + let rejected = makeRejected( + reason: "A newer version of TablePro is required for this plugin.", + providedDatabaseTypeIds: ["TestDriverType"] + ) + pm.rejectedPlugins.append(rejected) + defer { pm.removeFromRejected(url: rejected.url) } + #expect(pm.outdatedReconcileReason(forTypeId: "TestDriverType") == "A newer version of TablePro is required for this plugin.") + #expect(pm.outdatedReconcileReason(forTypeId: "OtherDriverType") == nil) + } + @Test("incompatible-build errors are permanent reconciliation failures") func permanentFailuresClassified() { #expect(PluginError.noCompatibleBinary.isPermanentReconciliationFailure) From f044302a34dc644610d36319daaf435723290da9 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: Thu, 28 May 2026 08:14:09 +0700 Subject: [PATCH 2/2] ci(plugins): verify the served binary ABI matches its registry label (#1380) --- .github/workflows/build-plugin.yml | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index d1a5add96..33b0cdfdb 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -324,6 +324,42 @@ jobs: build/Plugins/${BUNDLE_NAME}-arm64.zip \ build/Plugins/${BUNDLE_NAME}-x86_64.zip + - name: Verify published assets match the PluginKit label + run: | + BUNDLE_NAME="${{ steps.plugin.outputs.bundleName }}" + TAG="${{ matrix.tag }}" + PKV="${{ matrix.pluginKitVersion }}" + REPO="${{ github.repository }}" + for ARCH in arm64 x86_64; do + URL="https://github.com/${REPO}/releases/download/${TAG}/${BUNDLE_NAME}-${ARCH}.zip" + WORK=$(mktemp -d) + DOWNLOADED="" + for attempt in 1 2 3 4 5; do + if curl -fsSL "$URL" -o "$WORK/asset.zip"; then + DOWNLOADED="yes" + break + fi + echo "Published asset not downloadable yet (attempt $attempt/5), retrying in 5s..." + sleep 5 + done + if [ -z "$DOWNLOADED" ]; then + echo "::error::Could not download the published asset at $URL to verify its PluginKit version." + exit 1 + fi + unzip -oq "$WORK/asset.zip" -d "$WORK" + PLIST=$(find "$WORK" -path '*.tableplugin/Contents/Info.plist' | head -1) + if [ -z "$PLIST" ]; then + echo "::error::No .tableplugin Info.plist in the published ${BUNDLE_NAME}-${ARCH} asset." + exit 1 + fi + ACTUAL=$(plutil -extract TableProPluginKitVersion raw "$PLIST") + if [ "$ACTUAL" != "$PKV" ]; then + echo "::error::Published ${BUNDLE_NAME}-${ARCH} at $URL is PluginKit $ACTUAL but the registry will record $PKV. Refusing to record a mismatched binary." + exit 1 + fi + echo "Verified published ${BUNDLE_NAME}-${ARCH}: served PluginKit $ACTUAL matches the registry label." + done + - name: Update plugin registry if: ${{ env.REGISTRY_DEPLOY_KEY != '' }} env: