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
51 changes: 25 additions & 26 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,6 @@ final class PluginManager: ObservableObject {
self.builtInPluginsURL = builtInPluginsURL
self.userPluginsDir = userPluginsDir
self.initialLoadClock = initialLoadClock
Self.clearLegacyNeedsRestartKey(in: userDefaults)
}

nonisolated private static func clearLegacyNeedsRestartKey(in defaults: UserDefaults) {
let legacyKey = "com.TablePro.needsRestart"
if defaults.object(forKey: legacyKey) != nil {
defaults.removeObject(forKey: legacyKey)
}
}

nonisolated static func defaultUserPluginsDir() -> URL {
Expand All @@ -154,13 +146,6 @@ final class PluginManager: ObservableObject {
return try? JSONDecoder().decode(RegistryMetadata.self, from: data)
}

nonisolated static func bundleVersion(at pluginURL: URL) -> String? {
guard let bundle = Bundle(url: pluginURL),
let info = bundle.infoDictionary
else { return nil }
return info["CFBundleShortVersionString"] as? String
}

func saveRegistryMetadata(pluginId: String, pluginURL: URL) {
let metadata = RegistryMetadata(pluginId: pluginId)
let url = Self.metadataURL(for: pluginURL)
Expand Down Expand Up @@ -297,6 +282,7 @@ final class PluginManager: ObservableObject {
source: source,
name: displayName,
version: version,
build: Self.bundleBuild(at: url),
pluginDescription: pluginDescription,
capabilities: capabilities,
databaseTypeId: primaryTypeId,
Expand Down Expand Up @@ -518,6 +504,16 @@ final class PluginManager: ObservableObject {
return dictionary["CFBundleShortVersionString"] as? String
}

nonisolated static func bundleBuild(at url: URL) -> Int {
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 0
}
return (dictionary["CFBundleVersion"] as? String).flatMap { Int($0) } ?? 0
}

private struct EagerLoadFailure {
let url: URL
let reason: String
Expand Down Expand Up @@ -592,6 +588,7 @@ final class PluginManager: ObservableObject {
source: source,
name: principalClass.pluginName,
version: version,
build: Self.bundleBuild(at: url),
pluginDescription: principalClass.pluginDescription,
capabilities: principalClass.capabilities,
databaseTypeId: driverType?.databaseTypeId,
Expand Down Expand Up @@ -660,11 +657,11 @@ final class PluginManager: ObservableObject {
Self.logger.info("Discovered \(self.pendingPluginURLs.count) plugin(s), will load on first use")
}

private struct PluginCandidate {
internal struct PluginCandidate {
let url: URL
let source: PluginSource
let bundleId: String
let version: String
let build: Int
}

nonisolated private static func collectCandidates(
Expand All @@ -685,13 +682,17 @@ final class PluginManager: ObservableObject {
guard let bundle = Bundle(url: itemURL),
let bundleId = bundle.bundleIdentifier
else { continue }
let version = (bundle.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0"
results.append(PluginCandidate(url: itemURL, source: source, bundleId: bundleId, version: version))
results.append(PluginCandidate(
url: itemURL,
source: source,
bundleId: bundleId,
build: bundleBuild(at: itemURL)
))
}
return results
}

nonisolated private static func selectWinners(
nonisolated internal static func selectWinners(
candidates: [PluginCandidate]
) -> [String: PluginCandidate] {
var winners: [String: PluginCandidate] = [:]
Expand All @@ -700,9 +701,8 @@ final class PluginManager: ObservableObject {
winners[candidate.bundleId] = candidate
continue
}
let order = candidate.version.compare(existing.version, options: .numeric)
let candidateWins = order == .orderedDescending
|| (order == .orderedSame && candidate.source == .builtIn)
let candidateWins = candidate.build > existing.build
|| (candidate.build == existing.build && candidate.source == .builtIn)
if candidateWins {
winners[candidate.bundleId] = candidate
}
Expand All @@ -720,10 +720,9 @@ final class PluginManager: ObservableObject {
do {
try fm.removeItem(at: candidate.url)
Self.removeRegistryMetadataFile(for: candidate.url)
let order = candidate.version.compare(winner.version, options: .numeric)
let reason = order == .orderedSame ? "equal version" : "older version"
let reason = candidate.build == winner.build ? "equal build" : "older build"
Self.logger.info(
"Pruned user-installed '\(candidate.bundleId)' v\(candidate.version) (\(reason); \(winner.source == .builtIn ? "built-in" : "winning") v\(winner.version) takes precedence)"
"Pruned user-installed '\(candidate.bundleId)' build \(candidate.build) (\(reason); \(winner.source == .builtIn ? "built-in" : "winning") build \(winner.build) takes precedence)"
)
} catch {
Self.logger.warning(
Expand Down
6 changes: 1 addition & 5 deletions TablePro/Core/Plugins/PluginModels.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// PluginModels.swift
// TablePro
//

import Foundation
import TableProPluginKit

Expand All @@ -13,6 +8,7 @@ struct PluginEntry: Identifiable {
let source: PluginSource
let name: String
var version: String
var build: Int = 0
let pluginDescription: String
let capabilities: [PluginCapability]

Expand Down
89 changes: 57 additions & 32 deletions TableProTests/Core/Plugins/PluginBundleVersionTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// PluginBundleVersionTests.swift
// TableProTests
//

import Foundation
@testable import TablePro
import Testing
Expand All @@ -15,21 +10,14 @@ struct PluginBundleVersionTests {
return dir
}

private func writeBundle(at directory: URL, name: String, version: String) throws -> URL {
private func writeBundle(at directory: URL, name: String, info: [String: 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)
var plist = info
plist["CFBundleIdentifier"] = "com.example.\(name)"
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: contents.appendingPathComponent("Info.plist"))
return bundle
}

Expand All @@ -38,7 +26,7 @@ struct PluginBundleVersionTests {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

let bundle = try writeBundle(at: dir, name: "Driver", version: "1.2.3")
let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleShortVersionString": "1.2.3"])
#expect(PluginManager.bundleShortVersion(at: bundle) == "1.2.3")
}

Expand All @@ -47,18 +35,7 @@ struct PluginBundleVersionTests {
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)

let bundle = try writeBundle(at: dir, name: "Driver", info: [:])
#expect(PluginManager.bundleShortVersion(at: bundle) == nil)
}

Expand All @@ -67,14 +44,62 @@ struct PluginBundleVersionTests {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

let bundle = try writeBundle(at: dir, name: "Driver", version: "1.0.0")
let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleShortVersionString": "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")
_ = try writeBundle(at: dir, name: "Driver", info: ["CFBundleShortVersionString": "2.0.0"])

#expect(PluginManager.bundleShortVersion(at: bundle) == "2.0.0")
#expect(Bundle(url: bundle)?.infoDictionary?["CFBundleShortVersionString"] as? String == "1.0.0")
}

@Test("bundleBuild reads CFBundleVersion from disk")
func readsBuildFromDisk() throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleVersion": "820099"])
#expect(PluginManager.bundleBuild(at: bundle) == 820_099)
}

@Test("bundleBuild is 0 when CFBundleVersion is not an integer", arguments: ["1.2", "abc", ""])
func buildIsZeroWhenNotAnInteger(value: String) throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleVersion": value])
#expect(PluginManager.bundleBuild(at: bundle) == 0)
}

@Test("bundleBuild is 0 when CFBundleVersion is missing")
func buildIsZeroWhenMissing() throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

let bundle = try writeBundle(at: dir, name: "Driver", info: [:])
#expect(PluginManager.bundleBuild(at: bundle) == 0)
}

@Test("bundleBuild is 0 when there is no bundle on disk")
func buildIsZeroWithoutBundle() throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

#expect(PluginManager.bundleBuild(at: dir.appendingPathComponent("Missing.tableplugin")) == 0)
}

@Test("bundleBuild sees the new build after the bundle is replaced in place")
func seesNewBuildAfterInPlaceReplace() throws {
let dir = try makeTempDir()
defer { try? FileManager.default.removeItem(at: dir) }

let bundle = try writeBundle(at: dir, name: "Driver", info: ["CFBundleVersion": "820001"])
#expect(Bundle(url: bundle)?.infoDictionary?["CFBundleVersion"] as? String == "820001")

_ = try writeBundle(at: dir, name: "Driver", info: ["CFBundleVersion": "820099"])

#expect(PluginManager.bundleBuild(at: bundle) == 820_099)
}
}
61 changes: 61 additions & 0 deletions TableProTests/Core/Plugins/PluginWinnerSelectionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation
@testable import TablePro
import Testing

struct PluginWinnerSelectionTests {
private let bundleId = "com.TablePro.ExampleDriver"

private func candidate(_ source: PluginSource, build: Int) -> PluginManager.PluginCandidate {
let folder = source == .builtIn ? "/Applications/TablePro.app/Contents/PlugIns" : "/tmp/Plugins"
return PluginManager.PluginCandidate(
url: URL(fileURLWithPath: "\(folder)/ExampleDriver.tableplugin"),
source: source,
bundleId: bundleId,
build: build
)
}

private func winner(
builtIn: PluginManager.PluginCandidate,
user: PluginManager.PluginCandidate,
userFirst: Bool
) throws -> PluginManager.PluginCandidate {
let candidates = userFirst ? [user, builtIn] : [builtIn, user]
let winners = PluginManager.selectWinners(candidates: candidates)
#expect(winners.count == 1)
return try #require(winners[bundleId])
}

@Test("A user copy with a lower build loses to the built-in copy", arguments: [false, true])
func lowerBuildUserCopyLoses(userFirst: Bool) throws {
let builtIn = candidate(.builtIn, build: 820_099)
let user = candidate(.userInstalled, build: 820_001)

let picked = try winner(builtIn: builtIn, user: user, userFirst: userFirst)

#expect(picked.source == .builtIn)
#expect(picked.url == builtIn.url)
}

@Test("A user copy with a higher build wins over the built-in copy", arguments: [false, true])
func higherBuildUserCopyWins(userFirst: Bool) throws {
let builtIn = candidate(.builtIn, build: 820_099)
let user = candidate(.userInstalled, build: 820_199)

let picked = try winner(builtIn: builtIn, user: user, userFirst: userFirst)

#expect(picked.source == .userInstalled)
#expect(picked.url == user.url)
}

@Test("An equal build goes to the built-in copy", arguments: [false, true])
func tieGoesToBuiltIn(userFirst: Bool) throws {
let builtIn = candidate(.builtIn, build: 1)
let user = candidate(.userInstalled, build: 1)

let picked = try winner(builtIn: builtIn, user: user, userFirst: userFirst)

#expect(picked.source == .builtIn)
#expect(picked.url == builtIn.url)
}
}
Loading