diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index db9da5632e..92ba0ccbb5 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -793,6 +793,6 @@ extension PluginManager { } nonisolated static func registryPlugin(forTypeId pluginTypeId: String, in manifest: RegistryManifest?) -> RegistryPlugin? { - manifest?.plugins.first { $0.databaseTypeIds?.contains(pluginTypeId) == true } + manifest?.plugins.first { $0.databaseTypeIds.contains(pluginTypeId) } } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 5a96b81733..42cf17d609 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -274,7 +274,7 @@ final class PluginManager: ObservableObject { let displayName = registrySnapshot?.displayName ?? bundleId.split(separator: ".").last.map(String.init) ?? bundleId - let pluginIconName = registrySnapshot?.iconName ?? "puzzlepiece" + let pluginIconName = Self.iconName(forPrimaryTypeId: primaryTypeId) let defaultPort = registrySnapshot?.defaultPort let pluginDescription = registrySnapshot?.connection.tagline ?? "" @@ -309,6 +309,10 @@ final class PluginManager: ObservableObject { Self.logger.debug("Registered lazy plugin '\(bundleId)': drivers=\(manifest.providedDatabaseTypeIds), exports=\(manifest.providedExportFormatIds), imports=\(manifest.providedImportFormatIds)") } + nonisolated static func iconName(forPrimaryTypeId typeId: String?) -> String { + typeId.flatMap { PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: $0)?.iconName } ?? "puzzlepiece" + } + /// Takes back everything `registerLazyManifest` published for this bundle, so a plugin the app /// would refuse to activate stops being offered as an installed one. internal func withdrawPlugin(at url: URL, reason: Error) { diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index 763696bf35..d63e4fbd69 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -30,9 +30,8 @@ extension RegistryManifest: Decodable { internal struct RegistryPlugin: Decodable, Sendable, Identifiable { internal let id: String internal let name: String - internal let iconName: String? - internal let databaseTypeIds: [String]? - internal let exportFormatIds: [String]? + internal let databaseTypeIds: [String] + internal let exportFormatIds: [String] internal let releases: [RegistryRelease] } diff --git a/TablePro/Core/Services/Export/ExportFormatCatalog.swift b/TablePro/Core/Services/Export/ExportFormatCatalog.swift index f1ee56830a..b43e89079d 100644 --- a/TablePro/Core/Services/Export/ExportFormatCatalog.swift +++ b/TablePro/Core/Services/Export/ExportFormatCatalog.swift @@ -12,29 +12,17 @@ internal enum ExportFormatCatalog { "csv", "json", "sql", "xlsx", "md", "parquet", "mql" ] - // scripts/release/registry.py writes no exportFormatIds yet, so Parquet stays offered by hand until it does. - private static let unlistedInstallable = [ - InstallableExportFormat( - formatId: "parquet", - displayName: "Parquet", - registryPluginId: "com.TablePro.ParquetExportPlugin" - ) - ] - internal static func installable(in manifest: RegistryManifest?) -> [InstallableExportFormat] { - let listed = (manifest?.plugins ?? []).flatMap { plugin in - (plugin.exportFormatIds ?? []).map { formatId in + (manifest?.plugins ?? []).flatMap { plugin in + plugin.exportFormatIds.map { formatId in InstallableExportFormat(formatId: formatId, displayName: plugin.name, registryPluginId: plugin.id) } } - let listedFormatIds = Set(listed.map(\.formatId)) - return listed + unlistedInstallable.filter { !listedFormatIds.contains($0.formatId) } } - @MainActor internal static func notInstalled( installedFormatIds: Set, - manifest: RegistryManifest? = RegistryClient.shared.manifest + manifest: RegistryManifest? ) -> [InstallableExportFormat] { installable(in: manifest).filter { !installedFormatIds.contains($0.formatId) } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 98d51fe17c..d060cd1bba 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers struct ExportDialog: View { @ObservedObject private var pluginManager = PluginManager.shared + @ObservedObject private var registryClient = RegistryClient.shared private static let logger = Logger(subsystem: "com.TablePro", category: "ExportDialog") @Binding var isPresented: Bool @@ -219,7 +220,7 @@ struct ExportDialog: View { private var formatsToOffer: [InstallableExportFormat] { let installed = Set(pluginManager.allExportPlugins().map { type(of: $0).formatId }) - return ExportFormatCatalog.notInstalled(installedFormatIds: installed) + return ExportFormatCatalog.notInstalled(installedFormatIds: installed, manifest: registryClient.manifest) } private static func offerTag(for format: InstallableExportFormat) -> String { diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index c62d2446b8..b6c41bdd66 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -150,7 +150,7 @@ struct InstalledPluginsView: View { @ViewBuilder private func rejectedPluginRow(_ plugin: RejectedPlugin, action: RejectedPluginAction) -> some View { HStack(spacing: 8) { - PluginIconView(name: rejectedIconName(for: plugin)) + PluginIconView(name: PluginManager.iconName(forPrimaryTypeId: plugin.providedDatabaseTypeIds.first)) .font(.title3) .frame(width: 24, height: 24) .foregroundStyle(.tertiary) @@ -198,14 +198,6 @@ struct InstalledPluginsView: View { } } - private func rejectedIconName(for plugin: RejectedPlugin) -> String { - registryEntry(for: plugin)?.iconName ?? "puzzlepiece" - } - - private func registryEntry(for plugin: RejectedPlugin) -> RegistryPlugin? { - pluginManager.registryPlugin(for: plugin) - } - private func removeRejectedPlugin(_ plugin: RejectedPlugin) { let fm = FileManager.default if fm.fileExists(atPath: plugin.url.path) { diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift index 3a89827314..42910274dd 100644 --- a/TableProTests/Core/Export/ExportFormatCatalogTests.swift +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -117,40 +117,47 @@ struct ExportFormatCatalogTests { RegistryManifest(schemaVersion: RegistryManifest.supportedSchemaVersion, plugins: plugins) } - private func registryPlugin(id: String, name: String, exportFormatIds: [String]?) -> RegistryPlugin { + private func registryPlugin(id: String, name: String, exportFormatIds: [String]) -> RegistryPlugin { RegistryPlugin( id: id, name: name, - iconName: nil, databaseTypeIds: [], exportFormatIds: exportFormatIds, releases: [] ) } - private let handParquet = InstallableExportFormat( - formatId: "parquet", - displayName: "Parquet", - registryPluginId: "com.TablePro.ParquetExportPlugin" - ) - - @Test("Parquet is offered while the manifest names no export format") - @MainActor - func parquetOfferedWithoutManifestFormats() { + @Test("No manifest, or one that lists no export format, offers no install") + func noManifestOffersNoInstall() { let unlisted = manifest([ - registryPlugin(id: "com.TablePro.OracleDriver", name: "Oracle Driver", exportFormatIds: nil), - registryPlugin(id: "com.TablePro.ParquetExportPlugin", name: "Parquet Export", exportFormatIds: nil) + registryPlugin(id: "com.TablePro.OracleDriver", name: "Oracle Driver", exportFormatIds: []) ]) - #expect(ExportFormatCatalog.installable(in: nil) == [handParquet]) - #expect(ExportFormatCatalog.installable(in: unlisted) == [handParquet]) - #expect(ExportFormatCatalog.notInstalled(installedFormatIds: [], manifest: nil) == [handParquet]) - #expect(ExportFormatCatalog.notInstalled(installedFormatIds: ["parquet"], manifest: nil).isEmpty) + #expect(ExportFormatCatalog.installable(in: nil).isEmpty) + #expect(ExportFormatCatalog.installable(in: unlisted).isEmpty) + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: [], manifest: nil).isEmpty) + } + + @Test("A manifest entry's exportFormatIds decode into an install offer") + func decodedManifestOffersItsFormats() throws { + let json = """ + {"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": [{ + "id": "com.TablePro.ParquetExportPlugin", "name": "Parquet Export", + "databaseTypeIds": [], "exportFormatIds": ["parquet"], "releases": [] + }]} + """ + let decoded = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) + let parquet = InstallableExportFormat( + formatId: "parquet", + displayName: "Parquet Export", + registryPluginId: "com.TablePro.ParquetExportPlugin" + ) + + #expect(ExportFormatCatalog.installable(in: decoded) == [parquet]) } - @Test("A format the manifest lists is offered once, from the manifest") - @MainActor - func manifestFormatsReplaceTheHandEntry() { + @Test("Each format the manifest lists is offered under its plugin's name") + func manifestFormatsAreOffered() { let listed = manifest([ registryPlugin(id: "com.TablePro.ParquetExportPlugin", name: "Parquet Export", exportFormatIds: ["parquet"]), registryPlugin(id: "com.example.AvroExport", name: "Avro Export", exportFormatIds: ["avro"]) diff --git a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift index f896bdb93f..26a0257f4d 100644 --- a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift +++ b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift @@ -112,6 +112,7 @@ struct DriverUnavailabilityTests { "id": "com.example.future", "name": "Future Driver", "databaseTypeIds": ["\(typeId)"], + "exportFormatIds": [], "releases": [] }] } @@ -222,6 +223,7 @@ struct DriverUnavailabilityTests { "id": "com.example.eager", "name": "Eager Driver", "databaseTypeIds": ["EagerDB"], + "exportFormatIds": [], "releases": [] }] } diff --git a/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift b/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift index 23245e7f9a..a792ba10c4 100644 --- a/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyRegistrationTests.swift @@ -57,10 +57,19 @@ struct PluginLazyRegistrationTests { #expect(manager.lazyDriverURLs[DatabaseType.oracle.rawValue]?.lastPathComponent == url.lastPathComponent) #expect(manager.isDriverInstalled(for: .oracle)) #expect(manager.driverPlugins[DatabaseType.oracle.rawValue] == nil) - #expect(manager.plugins.first { $0.id == "com.TablePro.OracleDriver" }?.databaseTypeId == DatabaseType.oracle.rawValue) + let entry = manager.plugins.first { $0.id == "com.TablePro.OracleDriver" } + #expect(entry?.databaseTypeId == DatabaseType.oracle.rawValue) + #expect(entry?.pluginIconName == "oracle-icon") #expect(manager.rejectedPlugins.isEmpty) } + @Test("A plugin's icon is its primary type's snapshot icon, or the puzzle piece without one") + func iconComesFromThePrimaryTypeSnapshot() { + #expect(PluginManager.iconName(forPrimaryTypeId: DatabaseType.oracle.rawValue) == "oracle-icon") + #expect(PluginManager.iconName(forPrimaryTypeId: "NoSuchDB") == "puzzlepiece") + #expect(PluginManager.iconName(forPrimaryTypeId: nil) == "puzzlepiece") + } + @Test("A driver whose Info.plist declares no ids is loaded at launch") func unstampedDriverLoadsAtLaunch() async throws { let fixture = try makeFixture() diff --git a/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift b/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift index f059ab4ce8..0fb9f53f17 100644 --- a/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift +++ b/TableProTests/Core/Plugins/PluginRegistryUpdatePassTests.swift @@ -204,9 +204,8 @@ struct PluginRegistryUpdatePassTests { RegistryPlugin( id: id, name: "Test Plugin", - iconName: nil, - databaseTypeIds: nil, - exportFormatIds: nil, + databaseTypeIds: [], + exportFormatIds: [], releases: releases ) } @@ -252,7 +251,7 @@ struct PluginRegistryUpdatePassTests { let manifestURL = try #require(URL(string: "https://registry.invalid/\(UUID().uuidString)/plugins/v3.json")) let plugins = pluginIds.map { id in """ - {"id": "\(id)", "name": "Driver", "releases": [{ + {"id": "\(id)", "name": "Driver", "databaseTypeIds": [], "exportFormatIds": [], "releases": [{ "version": "1.0", "build": \(build), "channel": "stable", "pluginKit": \(PluginManager.currentPluginKitVersion), "minimumSystemVersion": "13.0", "url": "https://x", "sha256": "deadbeef" diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index 79bf4969a1..c2c5642809 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -60,6 +60,7 @@ struct RegistryClientFreshnessTests { "id": "com.test.freshness-driver", "name": "Freshness Test Driver", "databaseTypeIds": ["\(Self.testTypeId)"], + "exportFormatIds": [], "releases": [{ "version": "0.82.0", "build": 820099, "channel": "stable", "pluginKit": \(kit), "minimumSystemVersion": "13.0", "url": "https://x", "sha256": "deadbeef" diff --git a/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift b/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift index bed025ce38..4f6cea3775 100644 --- a/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift +++ b/TableProTests/Core/Plugins/RegistryReleaseSelectionTests.swift @@ -26,9 +26,8 @@ struct RegistryReleaseSelectionTests { RegistryPlugin( id: "com.example.driver", name: "Example Driver", - iconName: nil, databaseTypeIds: ["Example"], - exportFormatIds: nil, + exportFormatIds: [], releases: releases ) } @@ -72,7 +71,7 @@ struct RegistryReleaseSelectionTests { func unknownChannelSkipped() throws { let json = """ {"schemaVersion": 3, "retainFromPluginKit": 39, "plugins": [{ - "id": "com.example.driver", "name": "Example Driver", "databaseTypeIds": ["Example"], + "id": "com.example.driver", "name": "Example Driver", "databaseTypeIds": ["Example"], "exportFormatIds": [], "releases": [ {"version": "0.83.0-nightly.1", "build": 830050, "channel": "nightly", "pluginKit": 39, "minimumSystemVersion": "13.0", "url": "https://example.com/n.zip", "sha256": "\(String(repeating: "b", count: 64))"}, diff --git a/changelog.d/3515.removed.md b/changelog.d/3515.removed.md new file mode 100644 index 0000000000..bb441fe201 --- /dev/null +++ b/changelog.d/3515.removed.md @@ -0,0 +1 @@ +The `com.TablePro.customRegistryURL` setting for a custom plugin registry.