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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
13 changes: 11 additions & 2 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Don’t mark a cached bundle as the new version

When updating a plugin that has already been discovered or loaded at this same URL, loadPluginAsync still obtains and loads the Bundle before replaceExistingPlugin unloads the existing entry, so the bundle passed here can be the cached CFBundle object from the previous install. Reading only CFBundleShortVersionString from the replaced Info.plist then creates a PluginEntry that advertises the registry version while its principal class/capabilities can still come from the cached old bundle, causing registryUpdate(for:) to stop offering the update even though the running plugin code was not refreshed. Please avoid mixing the disk version with a cached Bundle (or unload/avoid the cached bundle before loading the replacement).

Useful? React with 👍 / 👎.

version = declared
} else {
Self.logger.warning("Plugin '\(bundleId)' missing CFBundleShortVersionString; defaulting to 0.0.0")
Expand Down
58 changes: 21 additions & 37 deletions TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -203,16 +170,34 @@ 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)
.controlSize(.mini)
}
}

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)
Expand Down Expand Up @@ -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) {
Expand Down
81 changes: 81 additions & 0 deletions TableProTests/Core/Plugins/PluginBundleVersionTests.swift
Original file line number Diff line number Diff line change
@@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>com.example.\(name)</string>
<key>CFBundleShortVersionString</key><string>\(version)</string>
</dict>
</plist>
"""
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict><key>CFBundleIdentifier</key><string>com.example.Driver</string></dict>
</plist>
"""
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")
}
}
Loading