Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 19 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Core/Plugins/PluginInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ actor PluginInstaller {
let context = await MainActor.run {
(
kit: PluginManager.currentPluginKitVersion,
minimumKit: PluginManager.minimumCompatiblePluginKitVersion,
inspector: PluginManager.currentInspectorKitVersion,
session: RegistryClient.shared.session
)
Expand Down Expand Up @@ -218,6 +219,7 @@ actor PluginInstaller {
try Self.validateStagedABI(
bundleURL: bundleURL,
currentKit: context.kit,
minimumKit: context.minimumKit,
currentInspector: context.inspector
)
Self.stripQuarantine(at: bundleURL)
Expand Down Expand Up @@ -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),
Expand All @@ -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)
}
}
Expand Down
92 changes: 85 additions & 7 deletions TablePro/Core/Plugins/PluginManager+AutoUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
Expand All @@ -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 }
Expand Down Expand Up @@ -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)
Comment on lines +262 to +263
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check minAppVersion before offering plugin updates

When the registry entry still has an old binary in the app's PluginKit range but its plugin-level minAppVersion has moved past the running app, this returns updateAvailable and the rejected-plugin UI shows an Update Now button. That update cannot succeed because validateRegistryCompatibility rejects registryPlugin.minAppVersion before downloading, so older app versions with retained compatible binaries get a misleading/retryable update path instead of the Update TablePro path.

Useful? React with 👍 / 👎.

}
if availableKits.contains(where: { $0 > currentKitVersion }) {
return .requiresAppUpdate
}
return .awaitingCompatibleBuild
}

func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool {
rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }
}
Expand All @@ -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()
}
}
4 changes: 3 additions & 1 deletion TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ extension PluginManager {
}
return try registryPlugin.resolvedBinary(
for: .current,
pluginKitVersion: Self.currentPluginKitVersion
currentKitVersion: Self.currentPluginKitVersion,
minimumKitVersion: Self.minimumCompatiblePluginKitVersion
)
}

Expand Down Expand Up @@ -226,6 +227,7 @@ extension PluginManager {
try PluginInstaller.validateStagedABI(
bundleURL: bundleURL,
currentKit: Self.currentPluginKitVersion,
minimumKit: Self.minimumCompatiblePluginKitVersion,
currentInspector: Self.currentInspectorKitVersion
)
PluginInstaller.stripQuarantine(at: bundleURL)
Expand Down
27 changes: 27 additions & 0 deletions TablePro/Core/Plugins/PluginManager+NetworkMonitor.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
7 changes: 6 additions & 1 deletion TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Combine
import Foundation
import Network
import os
import Security
import SwiftUI
Expand All @@ -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"
Expand Down Expand Up @@ -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<String> = []

var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:]
Expand Down Expand Up @@ -235,6 +239,7 @@ final class PluginManager {

self.refreshRegistryUpdateSet()
self.subscribeToConnectionStatusChanges()
self.startNetworkReachabilityMonitor()
self.scheduleReconciliation()
}
}
Expand Down Expand Up @@ -468,7 +473,7 @@ final class PluginManager {
current: currentPluginKitVersion
)
}
if version < currentPluginKitVersion {
if version < minimumCompatiblePluginKitVersion {
throw PluginError.pluginOutdated(
pluginVersion: version,
requiredVersion: currentPluginKitVersion
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Plugins/Registry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 11 additions & 4 deletions TablePro/Core/Plugins/Registry/RegistryModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading