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
91 changes: 91 additions & 0 deletions .github/scripts/test_update_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Tests for update-registry.py PluginKit version pruning (#1322).

Run: python3 .github/scripts/test_update_registry.py
"""
import importlib.util
import os

_spec = importlib.util.spec_from_file_location(
"update_registry",
os.path.join(os.path.dirname(__file__), "update-registry.py"),
)
update_registry = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(update_registry)


def test_kit_version_rejects_non_int():
assert update_registry.kit_version({"pluginKitVersion": 14}) == 14
assert update_registry.kit_version({"pluginKitVersion": None}) is None
assert update_registry.kit_version({}) is None


def test_prune_drops_null_kit_binary():
binaries = [
{"architecture": "arm64", "pluginKitVersion": None, "downloadURL": "legacy"},
{"architecture": "arm64", "pluginKitVersion": 14, "downloadURL": "v14"},
{"architecture": "arm64", "pluginKitVersion": 13, "downloadURL": "v13"},
]
kept = update_registry.prune_old_kit_versions(binaries, keep_count=2)
versions = sorted(update_registry.kit_version(b) for b in kept)
assert versions == [13, 14], versions
assert all(update_registry.kit_version(b) is not None for b in kept)


def test_prune_keeps_only_two_newest():
binaries = [
{"architecture": "arm64", "pluginKitVersion": v, "downloadURL": str(v)}
for v in (12, 13, 14)
]
kept = update_registry.prune_old_kit_versions(binaries, keep_count=2)
versions = sorted(update_registry.kit_version(b) for b in kept)
assert versions == [13, 14], versions


def test_update_entry_drops_legacy_null_binary():
manifest = {
"schemaVersion": 2,
"plugins": [
{
"id": "com.TablePro.DynamoDBDriverPlugin",
"name": "DynamoDB",
"version": "1.0.15",
"summary": "old",
"category": "database-driver",
"binaries": [
{"architecture": "arm64", "pluginKitVersion": None, "downloadURL": "legacy"},
],
}
],
}

class Args:
id = "com.TablePro.DynamoDBDriverPlugin"
name = "DynamoDB"
version = "1.0.16"
summary = "new"
db_type_ids = '["dynamodb"]'
arm64_url = "https://x/arm64"
arm64_sha = "a"
x86_64_url = "https://x/x86_64"
x86_64_sha = "b"
min_app_version = "0.43.0"
icon = "icon"
homepage = "https://tablepro.app"
category = "database-driver"
plugin_kit_version = 14
keep_kit_versions = 2

result = update_registry.update_plugin_entry(manifest, Args())
entry = next(p for p in result["plugins"] if p["id"] == Args.id)
kits = sorted(update_registry.kit_version(b) for b in entry["binaries"])
assert kits == [14, 14], kits
assert all(update_registry.kit_version(b) is not None for b in entry["binaries"])


if __name__ == "__main__":
test_kit_version_rejects_non_int()
test_prune_drops_null_kit_binary()
test_prune_keeps_only_two_newest()
test_update_entry_drops_legacy_null_binary()
print("All update-registry tests passed.")
18 changes: 14 additions & 4 deletions .github/scripts/update-registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,27 @@ def write_manifest_atomic(path, manifest):
raise


def kit_version(binary):
value = binary.get("pluginKitVersion")
return value if isinstance(value, int) else None


def prune_old_kit_versions(binaries, keep_count):
# Drop binaries without a concrete integer PluginKit version. The app matches
# binaries on an exact integer, so a null/missing version can never resolve and
# only shadows valid binaries (the cause of the DynamoDB noCompatibleBinary in #1322).
typed = [b for b in binaries if kit_version(b) is not None]

versions_seen = []
for binary in binaries:
pkv = binary.get("pluginKitVersion", 0)
for binary in typed:
pkv = kit_version(binary)
if pkv not in versions_seen:
versions_seen.append(pkv)

versions_seen.sort(reverse=True)
versions_to_keep = set(versions_seen[:keep_count])

return [b for b in binaries if b.get("pluginKitVersion", 0) in versions_to_keep]
return [b for b in typed if kit_version(b) in versions_to_keep]


def update_plugin_entry(manifest, args):
Expand Down Expand Up @@ -104,7 +114,7 @@ def update_plugin_entry(manifest, args):
if existing_entry is not None:
surviving = [
b for b in existing_entry.get("binaries", [])
if b.get("pluginKitVersion", 0) != pkv
if kit_version(b) is not None and kit_version(b) != pkv
]
merged_binaries = surviving + new_binaries
else:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Import connections and passwords from DataGrip, including SSH tunnels and SSL settings. The source app doesn't need to be running. (#1374)

### Fixed

- Plugins left incompatible after a TablePro update now update quietly in the background instead of showing a premature "could not be loaded" alert. You are only notified when no compatible version exists yet, and the message tells you what to do. (#1322)
- A plugin you download and install by hand is no longer blocked by macOS Gatekeeper once its signature is verified. (#1322)

## [0.43.3] - 2026-05-22

### Fixed
Expand Down
11 changes: 10 additions & 1 deletion TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ enum PluginError: LocalizedError {
case .incompatibleVersion(let required, let current):
return String(format: String(localized: "Plugin requires PluginKit version %d, but app provides version %d"), required, current)
case .pluginOutdated(let pluginVersion, let requiredVersion):
let format = String(localized: "Plugin was built with PluginKit version %d, but version %d is required. Please update the plugin.")
let format = String(localized: "Plugin was built for PluginKit version %d; this release of TablePro needs version %d.")
return String(format: format, pluginVersion, requiredVersion)
case .cannotUninstallBuiltIn:
return String(localized: "Built-in plugins cannot be uninstalled")
Expand Down Expand Up @@ -62,4 +62,13 @@ enum PluginError: LocalizedError {
if case .pluginOutdated = self { return true }
return false
}

var isPermanentReconciliationFailure: Bool {
switch self {
case .noCompatibleBinary, .incompatibleVersion, .incompatibleWithCurrentApp, .appVersionTooOld:
return true
default:
return false
}
}
}
163 changes: 121 additions & 42 deletions TablePro/Core/Plugins/PluginManager+AutoUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,68 +24,102 @@ extension PluginManager {
func runReconciliationLoop() async {
let outdated = rejectedPlugins.filter(\.isOutdated)
guard !outdated.isEmpty else {
AppEvents.shared.pluginsRejected.send(rejectedPlugins)
emitReconciliationOutcome()
refreshRegistryUpdateSet()
return
}

await RegistryClient.shared.fetchManifest()
refreshRegistryUpdateSet()
guard let manifest = RegistryClient.shared.manifest else {
Self.logger.warning("Reconciliation skipped: registry manifest unavailable")
AppEvents.shared.pluginsRejected.send(rejectedPlugins)
reconciliationManifestAttempts += 1
guard reconciliationManifestAttempts < ReconciliationConfig.maxAttempts else {
Self.logger.error("Reconciliation gave up: registry manifest unavailable")
applyReason(registryUnreachableReason(), to: outdated)
emitReconciliationOutcome()
return
}
Self.logger.warning("Reconciliation deferred: registry manifest unavailable, will retry")
scheduleReconciliationRetry()
return
}
reconciliationManifestAttempts = 0

var sawTransientFailure = false
var retryRemaining = false
for rejected in outdated {
guard !Task.isCancelled else { return }

guard let lookupId = resolveRegistryId(for: rejected, manifest: manifest),
let registryPlugin = manifest.plugins.first(where: { $0.id == lookupId }) else {
Self.logger.warning("Reconciliation: no registry entry for '\(rejected.name)'")
continue
}

let attempts = reconciliationAttempts[lookupId, default: 0]
guard attempts < ReconciliationConfig.maxAttempts else {
Self.logger.warning("Reconciliation: max attempts reached for '\(rejected.name)'")
continue
}

reconciliationAttempts[lookupId] = attempts + 1

do {
let outcome = try await updateFromRegistry(
registryPlugin,
existingPluginLoaded: false,
progress: { _ in }
)
switch outcome {
case .installed:
removeFromRejected(url: rejected.url)
reconciliationAttempts.removeValue(forKey: lookupId)
refreshRegistryUpdateSet()
Self.logger.info("Reconciliation: auto-updated '\(rejected.name)'")
case .staged:
Self.logger.info("Reconciliation: staged update for '\(rejected.name)' (live connections)")
if case .transient(let id) = await reconcile(rejected, manifest: manifest) {
sawTransientFailure = true
if reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts {
retryRemaining = true
}
} catch {
Self.logger.error("Reconciliation: update failed for '\(rejected.name)': \(error.localizedDescription)")
}
}

AppEvents.shared.pluginsRejected.send(rejectedPlugins)
scheduleReconciliationRetryIfNeeded(manifest: manifest)
if Self.reconciliationShouldRetry(sawTransientFailure: sawTransientFailure, retryRemaining: retryRemaining) {
scheduleReconciliationRetry()
return
}

emitReconciliationOutcome()
}

static func reconciliationShouldRetry(sawTransientFailure: Bool, retryRemaining: Bool) -> Bool {
sawTransientFailure && retryRemaining
}

private enum ReconcileOutcome {
case resolved
case permanent
case missing
case transient(id: String)
}

private func scheduleReconciliationRetryIfNeeded(manifest: RegistryManifest) {
let retryable = rejectedPlugins.filter(\.isOutdated).contains { rejected in
guard let id = resolveRegistryId(for: rejected, manifest: manifest) else { return false }
return reconciliationAttempts[id, default: 0] < ReconciliationConfig.maxAttempts
private func reconcile(_ rejected: RejectedPlugin, manifest: RegistryManifest) async -> ReconcileOutcome {
guard let lookupId = resolveRegistryId(for: rejected, manifest: manifest),
let registryPlugin = manifest.plugins.first(where: { $0.id == lookupId }) else {
Self.logger.warning("Reconciliation: no registry entry for '\(rejected.name)'")
updateRejectedReason(url: rejected.url, reason: missingFromRegistryReason())
return .missing
}

let attempts = reconciliationAttempts[lookupId, default: 0]
guard attempts < ReconciliationConfig.maxAttempts else { return .permanent }
reconciliationAttempts[lookupId] = attempts + 1

do {
let outcome = try await updateFromRegistry(
registryPlugin,
existingPluginLoaded: false,
progress: { _ in }
)
switch outcome {
case .installed:
refreshRegistryUpdateSet()
Self.logger.info("Reconciliation: auto-updated '\(rejected.name)'")
case .staged:
Self.logger.info("Reconciliation: staged '\(rejected.name)', will activate on disconnect")
}
removeFromRejected(url: rejected.url)
reconciliationAttempts.removeValue(forKey: lookupId)
return .resolved
} catch let error as PluginError where error.isPermanentReconciliationFailure {
reconciliationAttempts[lookupId] = ReconciliationConfig.maxAttempts
updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin))
Self.logger.error("Reconciliation: no compatible build for '\(rejected.name)'")
return .permanent
} catch {
Self.logger.error("Reconciliation: transient failure for '\(rejected.name)': \(error.localizedDescription)")
if reconciliationAttempts[lookupId, default: 0] >= ReconciliationConfig.maxAttempts {
updateRejectedReason(url: rejected.url, reason: temporaryFailureReason())
}
return .transient(id: lookupId)
}
guard retryable else { return }
}

let round = reconciliationAttempts.values.max() ?? 1
private func scheduleReconciliationRetry() {
let round = max(reconciliationAttempts.values.max() ?? 0, reconciliationManifestAttempts)
let delay = round <= 1 ? ReconciliationConfig.firstRetryDelay : ReconciliationConfig.secondRetryDelay
reconciliationTask = Task { [weak self] in
try? await Task.sleep(for: delay)
Expand All @@ -94,6 +128,51 @@ extension PluginManager {
}
}

private func emitReconciliationOutcome() {
AppEvents.shared.pluginsRejected.send(rejectedPlugins)
}

private func updateRejectedReason(url: URL, reason: String) {
guard let index = rejectedPlugins.firstIndex(where: { $0.url == url }) else { return }
let existing = rejectedPlugins[index]
rejectedPlugins[index] = RejectedPlugin(
url: existing.url,
bundleId: existing.bundleId,
registryId: existing.registryId,
name: existing.name,
reason: reason,
isOutdated: existing.isOutdated
)
}

private func incompatibleBuildReason(for registryPlugin: RegistryPlugin) -> String {
let availableKits = registryPlugin.binaries
.filter { $0.architecture == .current }
.compactMap(\.pluginKitVersion)
if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) {
return String(localized: "A newer version of TablePro is required for this plugin. Update TablePro to keep using it.")
}
return String(localized: "No compatible build is available yet. This plugin will update automatically once one is published.")
}

private func missingFromRegistryReason() -> String {
String(localized: "This plugin is not in the registry, so it can't be updated automatically.")
}

private func registryUnreachableReason() -> String {
String(localized: "TablePro couldn't reach the plugin registry to update this plugin. Check your connection and reopen TablePro.")
}

private func temporaryFailureReason() -> String {
String(localized: "Updating this plugin didn't finish. TablePro will try again the next time it launches.")
}

private func applyReason(_ reason: String, to plugins: [RejectedPlugin]) {
for plugin in plugins {
updateRejectedReason(url: plugin.url, reason: reason)
}
}

func resolveRegistryId(for rejected: RejectedPlugin, manifest: RegistryManifest) -> String? {
if let id = rejected.registryId { return id }
if let bundleId = rejected.bundleId,
Expand Down
9 changes: 6 additions & 3 deletions TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,14 @@ extension PluginManager {
let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent)
let replaceId: String? = plugins.contains(where: { $0.id == bundleId }) ? bundleId : nil

let loadURL: URL
if url.standardizedFileURL != destURL.standardizedFileURL {
let finalURL = try PluginInstaller.atomicReplace(stagedBundleURL: url, destURL: destURL)
return try await loadPluginAsync(at: finalURL, source: .userInstalled, replacingBundleId: replaceId)
loadURL = try PluginInstaller.atomicReplace(stagedBundleURL: url, destURL: destURL)
} else {
loadURL = destURL
}
return try await loadPluginAsync(at: destURL, source: .userInstalled, replacingBundleId: replaceId)
PluginInstaller.stripQuarantine(at: loadURL)
return try await loadPluginAsync(at: loadURL, source: .userInstalled, replacingBundleId: replaceId)
}

private func installLocalZip(from url: URL) async throws -> PluginEntry {
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ final class PluginManager {

@ObservationIgnored internal var reconciliationTask: Task<Void, Never>?
@ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:]
@ObservationIgnored internal var reconciliationManifestAttempts = 0
@ObservationIgnored private var connectionStatusSubscription: AnyCancellable?
@ObservationIgnored internal var installsInFlight: Set<String> = []

Expand Down
Loading
Loading