diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a9460467..4d1022bb4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle connections that use native network encryption no longer crash when a query hits a server error such as a missing table or a permission error; the real ORA error is shown and the connection keeps working. (#483)
- Clicking a table that's already open switches to its existing tab instead of opening a duplicate. (#1613)
- MongoDB now connects over an SSH or Cloudflare tunnel instead of bypassing it and failing with a connection refused error. (#1621)
+- A plugin updated in Settings now stays marked Installed instead of showing the Update button again a few seconds later.
## [0.49.1] - 2026-06-06
diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift
index b0f2eb426..e3a218f4b 100644
--- a/TablePro/Core/Plugins/PluginManager+Install.swift
+++ b/TablePro/Core/Plugins/PluginManager+Install.swift
@@ -94,7 +94,6 @@ extension PluginManager {
replacingBundleId: registryPlugin.id
)
stagedUpdates.removeValue(forKey: registryPlugin.id)
- PluginInstallTracker.shared.completeInstall(pluginId: registryPlugin.id)
refreshRegistryUpdateSet()
return .installed(entry)
case .staged(let stagedURL):
diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift
index 54fdae282..ac4ba944c 100644
--- a/TablePro/Core/Plugins/PluginManager.swift
+++ b/TablePro/Core/Plugins/PluginManager.swift
@@ -521,6 +521,16 @@ final class PluginManager {
return bundle
}
+ nonisolated static func bundleShortVersion(at url: URL) -> String? {
+ let infoPlistURL = url.appendingPathComponent("Contents/Info.plist")
+ guard let data = try? Data(contentsOf: infoPlistURL),
+ let plist = try? PropertyListSerialization.propertyList(from: data, format: nil),
+ let dictionary = plist as? [String: Any] else {
+ return nil
+ }
+ return dictionary["CFBundleShortVersionString"] as? String
+ }
+
nonisolated private static func validateAndLoadBundles(
_ pending: [(url: URL, source: PluginSource)]
) async -> [ValidatedBundle] {
@@ -550,9 +560,8 @@ final class PluginManager {
let inspectorType = principalClass as? any DocumentInspectorPlugin.Type
let disabled = disabledPluginIds
- let info = bundle.infoDictionary ?? [:]
let version: String
- if let declared = info["CFBundleShortVersionString"] as? String {
+ if let declared = Self.bundleShortVersion(at: url) {
version = declared
} else {
Self.logger.warning("Plugin '\(bundleId)' missing CFBundleShortVersionString; defaulting to 0.0.0")
diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
index 76d3153c1..2622fc5cd 100644
--- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
+++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
@@ -152,40 +152,7 @@ struct BrowsePluginsView: View {
@ViewBuilder
private func rowStatusBadge(for plugin: RegistryPlugin) -> some View {
- if isPluginInstalled(plugin.id) {
- if hasUpdate(for: plugin) {
- if let progress = installTracker.state(for: plugin.id) {
- switch progress.phase {
- case .downloading(let fraction):
- ProgressView(value: fraction)
- .frame(width: 40)
- .progressViewStyle(.linear)
- case .installing:
- ProgressView()
- .controlSize(.mini)
- case .stagedPendingActivation:
- Image(systemName: "clock.arrow.circlepath")
- .foregroundStyle(.orange)
- .font(.caption)
- case .completed:
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- .font(.caption)
- case .failed:
- Button("Retry") { updatePlugin(plugin) }
- .controlSize(.mini)
- }
- } else {
- Button(String(localized: "Update")) { updatePlugin(plugin) }
- .buttonStyle(.bordered)
- .controlSize(.mini)
- }
- } else {
- Text("Installed")
- .font(.caption2)
- .foregroundStyle(.secondary)
- }
- } else if let progress = installTracker.state(for: plugin.id) {
+ if let progress = installTracker.state(for: plugin.id) {
switch progress.phase {
case .downloading(let fraction):
ProgressView(value: fraction)
@@ -203,9 +170,19 @@ struct BrowsePluginsView: View {
.foregroundStyle(.green)
.font(.caption)
case .failed:
- Button("Retry") { installPlugin(plugin) }
+ Button("Retry") { retryOperation(for: plugin) }
.controlSize(.mini)
}
+ } else if isPluginInstalled(plugin.id) {
+ if hasUpdate(for: plugin) {
+ Button(String(localized: "Update")) { updatePlugin(plugin) }
+ .buttonStyle(.bordered)
+ .controlSize(.mini)
+ } else {
+ Text("Installed")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
} else {
Button("Install") { installPlugin(plugin) }
.buttonStyle(.bordered)
@@ -213,6 +190,14 @@ struct BrowsePluginsView: View {
}
}
+ private func retryOperation(for plugin: RegistryPlugin) {
+ if isPluginInstalled(plugin.id) {
+ updatePlugin(plugin)
+ } else {
+ installPlugin(plugin)
+ }
+ }
+
private func formattedCount(_ count: Int) -> String {
if count >= 1_000 {
return String(format: "%.1fk", Double(count) / 1_000.0)
@@ -255,8 +240,7 @@ struct BrowsePluginsView: View {
}
private func hasUpdate(for plugin: RegistryPlugin) -> Bool {
- guard let installed = pluginManager.plugins.first(where: { $0.id == plugin.id }) else { return false }
- return plugin.version.compare(installed.version, options: .numeric) == .orderedDescending
+ pluginManager.registryUpdate(for: plugin.id) != nil
}
private func installPlugin(_ plugin: RegistryPlugin) {
diff --git a/TableProTests/Core/Plugins/PluginBundleVersionTests.swift b/TableProTests/Core/Plugins/PluginBundleVersionTests.swift
new file mode 100644
index 000000000..cc1a2d1da
--- /dev/null
+++ b/TableProTests/Core/Plugins/PluginBundleVersionTests.swift
@@ -0,0 +1,81 @@
+//
+// PluginBundleVersionTests.swift
+// TableProTests
+//
+
+import Foundation
+@testable import TablePro
+import Testing
+
+@Suite("Plugin bundle version reading", .serialized)
+struct PluginBundleVersionTests {
+ private func makeTempDir() throws -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("PluginBundleVersionTests-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+ }
+
+ private func writeBundle(at directory: URL, name: String, version: String) throws -> URL {
+ let bundle = directory.appendingPathComponent("\(name).tableplugin", isDirectory: true)
+ let contents = bundle.appendingPathComponent("Contents", isDirectory: true)
+ try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true)
+ let payload = """
+
+
+
+
+ CFBundleIdentifiercom.example.\(name)
+ CFBundleShortVersionString\(version)
+
+
+ """
+ try payload.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8)
+ return bundle
+ }
+
+ @Test("bundleShortVersion reads CFBundleShortVersionString from disk")
+ func readsVersionFromDisk() throws {
+ let dir = try makeTempDir()
+ defer { try? FileManager.default.removeItem(at: dir) }
+
+ let bundle = try writeBundle(at: dir, name: "Driver", version: "1.2.3")
+ #expect(PluginManager.bundleShortVersion(at: bundle) == "1.2.3")
+ }
+
+ @Test("bundleShortVersion returns nil when the key is missing")
+ func returnsNilWhenMissing() throws {
+ let dir = try makeTempDir()
+ defer { try? FileManager.default.removeItem(at: dir) }
+
+ let bundle = dir.appendingPathComponent("Driver.tableplugin", isDirectory: true)
+ let contents = bundle.appendingPathComponent("Contents", isDirectory: true)
+ try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true)
+ let payload = """
+
+
+
+ CFBundleIdentifiercom.example.Driver
+
+ """
+ try payload.write(to: contents.appendingPathComponent("Info.plist"), atomically: true, encoding: .utf8)
+
+ #expect(PluginManager.bundleShortVersion(at: bundle) == nil)
+ }
+
+ @Test("bundleShortVersion sees the new version after the bundle is replaced in place")
+ func seesNewVersionAfterInPlaceReplace() throws {
+ let dir = try makeTempDir()
+ defer { try? FileManager.default.removeItem(at: dir) }
+
+ let bundle = try writeBundle(at: dir, name: "Driver", version: "1.0.0")
+
+ let cached = Bundle(url: bundle)
+ #expect(cached?.infoDictionary?["CFBundleShortVersionString"] as? String == "1.0.0")
+
+ _ = try writeBundle(at: dir, name: "Driver", version: "2.0.0")
+
+ #expect(PluginManager.bundleShortVersion(at: bundle) == "2.0.0")
+ #expect(Bundle(url: bundle)?.infoDictionary?["CFBundleShortVersionString"] as? String == "1.0.0")
+ }
+}