Skip to content

Commit 9ea6314

Browse files
authored
fix(plugins): self-heal incompatible drivers at connect instead of blocking (#1552) (#1555)
* fix(plugins): gate the rejected-plugin Update button and cap the unloaded banner * refactor(plugins): extract a pure rejected-plugin action resolver and cover it with tests * fix(plugins): self-heal incompatible drivers at connect instead of blocking (#1552) * perf(plugins): reconcile only the connecting driver at connect, debounce network retriggers
1 parent c24cae1 commit 9ea6314

17 files changed

Lines changed: 500 additions & 45 deletions

.github/workflows/build-plugin.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,9 @@ jobs:
425425
426426
if git push; then
427427
echo "Registry updated on attempt $attempt"
428+
curl -sf "https://purge.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json" >/dev/null \
429+
&& echo "Purged jsDelivr cache for plugins.json" \
430+
|| echo "::warning::jsDelivr purge request failed; the cache will refresh on its own shortly"
428431
break
429432
fi
430433

.github/workflows/build.yml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,10 +316,28 @@ jobs:
316316
build/Release/TablePro-*.dmg
317317
build/Release/TablePro-*.zip
318318
319+
registry-readiness:
320+
name: Registry Readiness
321+
runs-on: macos-26
322+
if: startsWith(github.ref, 'refs/tags/v')
323+
timeout-minutes: 5
324+
325+
steps:
326+
- name: Checkout code
327+
uses: actions/checkout@v4
328+
329+
- name: Verify registry has compatible plugin binaries
330+
run: |
331+
MANAGER="TablePro/Core/Plugins/PluginManager.swift"
332+
CURRENT=$(grep -E 'static let currentPluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1)
333+
FLOOR=$(grep -E 'static let minimumCompatiblePluginKitVersion = ' "$MANAGER" | grep -oE '[0-9]+' | head -1)
334+
echo "PluginKit floor=$FLOOR current=$CURRENT"
335+
python3 scripts/check-registry-readiness.py --floor "$FLOOR" --current "$CURRENT"
336+
319337
release:
320338
name: Create GitHub Release
321339
runs-on: macos-26
322-
needs: [lint, test, build-arm64, build-x86_64]
340+
needs: [lint, test, build-arm64, build-x86_64, registry-readiness]
323341
if: startsWith(github.ref, 'refs/tags/v')
324342
timeout-minutes: 10
325343
permissions:

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
- Import now finds the Setapp edition of TablePlus and reads its connections. (#1528)
3232
- Favorite keyword suggestions now appear in editor autocomplete. They were dropped before reaching the popup.
3333
- Editor autocomplete refreshes when you switch schema, suggesting the new schema's tables and columns.
34+
- 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.
35+
- 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)
3436

3537
## [0.47.0] - 2026-06-01
3638

TablePro/Core/Database/DatabaseDriver.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,18 @@ enum DatabaseDriverFactory {
408408
}
409409
if PluginManager.shared.driverPlugin(for: connection.type) == nil,
410410
PluginManager.shared.hasOutdatedRejectedPlugin(forTypeId: pluginId) {
411-
logger.info("Plugin '\(pluginId)' is installed but outdated, waiting for reconciliation to update it")
412-
await PluginManager.shared.awaitReconciliation()
411+
logger.info("Plugin '\(pluginId)' is installed but outdated, updating it before connect")
412+
await PluginManager.shared.ensurePluginReady(forTypeId: pluginId)
413+
}
414+
if PluginManager.shared.driverPlugin(for: connection.type) == nil,
415+
connection.type.isDownloadablePlugin,
416+
!PluginManager.shared.hasOutdatedRejectedPlugin(forTypeId: pluginId) {
417+
logger.info("Plugin '\(pluginId)' not installed, installing on demand before connect")
418+
do {
419+
try await PluginManager.shared.installMissingPlugin(for: connection.type) { _ in }
420+
} catch {
421+
logger.warning("On-demand install for '\(pluginId)' did not complete: \(error.localizedDescription)")
422+
}
413423
}
414424
return try await createDriverFromPlugin(for: connection, passwordOverride: passwordOverride)
415425
}

TablePro/Core/Plugins/PluginInstaller.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ actor PluginInstaller {
175175
let context = await MainActor.run {
176176
(
177177
kit: PluginManager.currentPluginKitVersion,
178+
minimumKit: PluginManager.minimumCompatiblePluginKitVersion,
178179
inspector: PluginManager.currentInspectorKitVersion,
179180
session: RegistryClient.shared.session
180181
)
@@ -218,6 +219,7 @@ actor PluginInstaller {
218219
try Self.validateStagedABI(
219220
bundleURL: bundleURL,
220221
currentKit: context.kit,
222+
minimumKit: context.minimumKit,
221223
currentInspector: context.inspector
222224
)
223225
Self.stripQuarantine(at: bundleURL)
@@ -272,6 +274,7 @@ actor PluginInstaller {
272274
nonisolated static func validateStagedABI(
273275
bundleURL: URL,
274276
currentKit: Int,
277+
minimumKit: Int,
275278
currentInspector: Int
276279
) throws {
277280
guard let bundle = Bundle(url: bundleURL),
@@ -288,7 +291,7 @@ actor PluginInstaller {
288291
if version > currentKit {
289292
throw PluginError.incompatibleVersion(required: version, current: currentKit)
290293
}
291-
if version < currentKit {
294+
if version < minimumKit {
292295
throw PluginError.pluginOutdated(pluginVersion: version, requiredVersion: currentKit)
293296
}
294297
}

TablePro/Core/Plugins/PluginManager+AutoUpdate.swift

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ import Foundation
88
import os
99

1010
private enum ReconciliationConfig {
11-
static let maxAttempts = 3
11+
static let maxAttempts = 5
1212
static let firstRetryDelay: Duration = .seconds(30)
1313
static let secondRetryDelay: Duration = .seconds(300)
14+
static let thirdRetryDelay: Duration = .seconds(600)
15+
}
16+
17+
enum RejectedPluginAction: Sendable {
18+
case updateAvailable(RegistryPlugin)
19+
case awaitingCompatibleBuild
20+
case requiresAppUpdate
21+
case notInRegistry
1422
}
1523

1624
extension PluginManager {
@@ -108,9 +116,19 @@ extension PluginManager {
108116
reconciliationAttempts.removeValue(forKey: lookupId)
109117
return .resolved
110118
} catch let error as PluginError where error.isPermanentReconciliationFailure {
111-
reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts
119+
let action = Self.rejectedAction(
120+
registryPlugin: registryPlugin,
121+
manifestLoaded: true,
122+
currentKitVersion: Self.currentPluginKitVersion,
123+
minimumKitVersion: Self.minimumCompatiblePluginKitVersion
124+
)
112125
updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin))
113-
Self.logger.error("Reconciliation: no compatible build for '\(rejected.name)'")
126+
if case .noCompatibleBinary = error, case .awaitingCompatibleBuild = action {
127+
Self.logger.warning("Reconciliation: no compatible build published yet for '\(rejected.name)', will retry")
128+
return .transient(id: lookupId)
129+
}
130+
reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts
131+
Self.logger.error("Reconciliation: '\(rejected.name)' needs a newer app or has no compatible build")
114132
return .permanent
115133
} catch {
116134
Self.logger.error("Reconciliation: transient failure for '\(rejected.name)': \(error.localizedDescription)")
@@ -123,7 +141,12 @@ extension PluginManager {
123141

124142
private func scheduleReconciliationRetry() {
125143
let round = max(reconciliationAttempts.values.max() ?? 0, reconciliationManifestAttempts)
126-
let delay = round <= 1 ? ReconciliationConfig.firstRetryDelay : ReconciliationConfig.secondRetryDelay
144+
let delay: Duration
145+
switch round {
146+
case ..<2: delay = ReconciliationConfig.firstRetryDelay
147+
case 2: delay = ReconciliationConfig.secondRetryDelay
148+
default: delay = ReconciliationConfig.thirdRetryDelay
149+
}
127150
reconciliationTask = Task { [weak self] in
128151
try? await Task.sleep(for: delay)
129152
guard !Task.isCancelled else { return }
@@ -216,6 +239,35 @@ extension PluginManager {
216239
return manifest.plugins.first(where: { $0.id == id })
217240
}
218241

242+
func rejectedAction(for rejected: RejectedPlugin) -> RejectedPluginAction {
243+
Self.rejectedAction(
244+
registryPlugin: registryPlugin(for: rejected),
245+
manifestLoaded: RegistryClient.shared.manifest != nil,
246+
currentKitVersion: Self.currentPluginKitVersion,
247+
minimumKitVersion: Self.minimumCompatiblePluginKitVersion
248+
)
249+
}
250+
251+
static func rejectedAction(
252+
registryPlugin: RegistryPlugin?,
253+
manifestLoaded: Bool,
254+
currentKitVersion: Int,
255+
minimumKitVersion: Int
256+
) -> RejectedPluginAction {
257+
guard manifestLoaded else { return .awaitingCompatibleBuild }
258+
guard let registryPlugin else { return .notInRegistry }
259+
let availableKits = registryPlugin.binaries
260+
.filter { $0.architecture == .current }
261+
.compactMap(\.pluginKitVersion)
262+
if availableKits.contains(where: { $0 >= minimumKitVersion && $0 <= currentKitVersion }) {
263+
return .updateAvailable(registryPlugin)
264+
}
265+
if availableKits.contains(where: { $0 > currentKitVersion }) {
266+
return .requiresAppUpdate
267+
}
268+
return .awaitingCompatibleBuild
269+
}
270+
219271
func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool {
220272
rejectedPlugins.contains { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }
221273
}
@@ -224,8 +276,34 @@ extension PluginManager {
224276
rejectedPlugins.first { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }?.reason
225277
}
226278

227-
func awaitReconciliation() async {
228-
guard reconciliationActive, let task = reconciliationTask else { return }
229-
await task.value
279+
func ensurePluginReady(forTypeId typeId: String) async {
280+
if reconciliationActive, let task = reconciliationTask {
281+
await task.value
282+
}
283+
guard hasOutdatedRejectedPlugin(forTypeId: typeId) else { return }
284+
await reconcileOutdated(matchingTypeId: typeId)
285+
}
286+
287+
private func reconcileOutdated(matchingTypeId typeId: String) async {
288+
let targets = rejectedPlugins.filter { $0.isOutdated && $0.providedDatabaseTypeIds.contains(typeId) }
289+
guard !targets.isEmpty else { return }
290+
await RegistryClient.shared.fetchManifest(forceRefresh: true)
291+
guard let manifest = RegistryClient.shared.manifest else { return }
292+
for target in targets {
293+
if let lookupId = resolveRegistryId(for: target, manifest: manifest) {
294+
reconciliationAttempts.removeValue(forKey: lookupId)
295+
}
296+
_ = await reconcile(target, manifest: manifest)
297+
}
298+
refreshRegistryUpdateSet()
299+
emitReconciliationOutcome()
300+
}
301+
302+
func retriggerReconciliation() {
303+
guard !reconciliationActive else { return }
304+
guard rejectedPlugins.contains(where: \.isOutdated) else { return }
305+
reconciliationAttempts.removeAll()
306+
reconciliationManifestAttempts = 0
307+
scheduleReconciliation()
230308
}
231309
}

TablePro/Core/Plugins/PluginManager+Install.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ extension PluginManager {
167167
}
168168
return try registryPlugin.resolvedBinary(
169169
for: .current,
170-
pluginKitVersion: Self.currentPluginKitVersion
170+
currentKitVersion: Self.currentPluginKitVersion,
171+
minimumKitVersion: Self.minimumCompatiblePluginKitVersion
171172
)
172173
}
173174

@@ -226,6 +227,7 @@ extension PluginManager {
226227
try PluginInstaller.validateStagedABI(
227228
bundleURL: bundleURL,
228229
currentKit: Self.currentPluginKitVersion,
230+
minimumKit: Self.minimumCompatiblePluginKitVersion,
229231
currentInspector: Self.currentInspectorKitVersion
230232
)
231233
PluginInstaller.stripQuarantine(at: bundleURL)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// PluginManager+NetworkMonitor.swift
3+
// TablePro
4+
//
5+
6+
import Network
7+
8+
extension PluginManager {
9+
func startNetworkReachabilityMonitor() {
10+
guard pluginNetworkMonitor == nil else { return }
11+
let monitor = NWPathMonitor()
12+
pluginNetworkMonitor = monitor
13+
monitor.pathUpdateHandler = { [weak self] path in
14+
let satisfied = path.status == .satisfied
15+
Task { @MainActor [weak self] in
16+
self?.handleNetworkPathChange(satisfied: satisfied)
17+
}
18+
}
19+
monitor.start(queue: DispatchQueue(label: "com.TablePro.pluginNetworkMonitor"))
20+
}
21+
22+
private func handleNetworkPathChange(satisfied: Bool) {
23+
defer { lastNetworkSatisfied = satisfied }
24+
guard satisfied, !lastNetworkSatisfied else { return }
25+
retriggerReconciliation()
26+
}
27+
}

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import Combine
77
import Foundation
8+
import Network
89
import os
910
import Security
1011
import SwiftUI
@@ -14,6 +15,7 @@ import TableProPluginKit
1415
final class PluginManager {
1516
static let shared = PluginManager()
1617
static let currentPluginKitVersion = 18
18+
static let minimumCompatiblePluginKitVersion = 18
1719
static let currentInspectorKitVersion = 1
1820
private static let disabledPluginsKey = "com.TablePro.disabledPlugins"
1921
private static let legacyDisabledPluginsKey = "disabledPlugins"
@@ -115,6 +117,8 @@ final class PluginManager {
115117
@ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:]
116118
@ObservationIgnored internal var reconciliationManifestAttempts = 0
117119
@ObservationIgnored private var connectionStatusSubscription: AnyCancellable?
120+
@ObservationIgnored internal var pluginNetworkMonitor: NWPathMonitor?
121+
@ObservationIgnored internal var lastNetworkSatisfied = false
118122
@ObservationIgnored internal var installsInFlight: Set<String> = []
119123

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

236240
self.refreshRegistryUpdateSet()
237241
self.subscribeToConnectionStatusChanges()
242+
self.startNetworkReachabilityMonitor()
238243
self.scheduleReconciliation()
239244
}
240245
}
@@ -468,7 +473,7 @@ final class PluginManager {
468473
current: currentPluginKitVersion
469474
)
470475
}
471-
if version < currentPluginKitVersion {
476+
if version < minimumCompatiblePluginKitVersion {
472477
throw PluginError.pluginOutdated(
473478
pluginVersion: version,
474479
requiredVersion: currentPluginKitVersion

TablePro/Core/Plugins/Registry/RegistryClient.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ final class RegistryClient {
2424
private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient")
2525

2626
private static let defaultRegistryURL = URL(string:
27-
"https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")! // swiftlint:disable:this force_unwrapping
27+
"https://cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json")! // swiftlint:disable:this force_unwrapping
2828

2929
static let customRegistryURLKey = "com.TablePro.customRegistryURL"
3030
private static let lastRegistryURLKey = "com.TablePro.lastRegistryURL"

0 commit comments

Comments
 (0)