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") + } +}