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
36 changes: 36 additions & 0 deletions .github/workflows/build-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- BigQuery: switching to another table now loads its data right away, instead of leaving the grid empty until you close and reopen the tab.
- 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)
- Opening a saved connection that fails now shows the detailed troubleshooting dialog with suggested fixes, the same one Test Connection shows, instead of a generic error alert. (#1425, #483)
- Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483)
- AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291)
Expand Down
10 changes: 9 additions & 1 deletion TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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):
Expand Down
18 changes: 17 additions & 1 deletion TablePro/Core/Plugins/PluginManager+AutoUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -142,7 +144,8 @@ extension PluginManager {
registryId: existing.registryId,
name: existing.name,
reason: reason,
isOutdated: existing.isOutdated
isOutdated: existing.isOutdated,
providedDatabaseTypeIds: existing.providedDatabaseTypeIds
)
}

Expand Down Expand Up @@ -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
}
}
10 changes: 7 additions & 3 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ final class PluginManager {
@ObservationIgnored private var activatedBundleIds: Set<String> = []

@ObservationIgnored internal var reconciliationTask: Task<Void, Never>?
@ObservationIgnored internal var reconciliationActive = false
@ObservationIgnored internal var reconciliationAttempts: [String: Int] = [:]
@ObservationIgnored internal var reconciliationManifestAttempts = 0
@ObservationIgnored private var connectionStatusSubscription: AnyCancellable?
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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 } ?? []
))
}
}
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Plugins/PluginModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct RejectedPlugin: Sendable {
let name: String
let reason: String
let isOutdated: Bool
let providedDatabaseTypeIds: [String]
}

extension PluginEntry {
Expand Down
41 changes: 38 additions & 3 deletions TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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)
Expand Down
Loading