diff --git a/.github/workflows/build-plugin.yml b/.github/workflows/build-plugin.yml index 6915fe1da6..2b832fdf0c 100644 --- a/.github/workflows/build-plugin.yml +++ b/.github/workflows/build-plugin.yml @@ -310,7 +310,7 @@ jobs: Plugin release for TablePro (PluginKit $PKV). ### Installation - TablePro will prompt you to install this plugin automatically when you select the database type. You can also install manually via Settings > Plugins > Browse. + TablePro offers to install this plugin where it is needed. To install it by hand, add the zip below in Settings > Plugins. ### SHA-256 - ARM64: \`$ARM64_SHA\` diff --git a/CHANGELOG.md b/CHANGELOG.md index d5618ebd82..193c64c5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interface, Sidebar and Toolbar color groups from the theme editor and the theme file. - Fonts from the theme file. - Loading plugins signed by other developers, and Trusted Plugin Developers in Settings > Plugins. +- Browse tab and plugin download counts in Settings > Plugins. +- On/off switch for each plugin in Settings > Plugins. +- Theme install from the plugin registry. ### Fixed diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index 87732e6854..75be6dd9f7 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -25,7 +25,6 @@ enum PluginError: LocalizedError { case incompatibleWithCurrentApp(minimumRequired: String) case invalidDescriptor(pluginId: String, reason: String) case unknownDatabaseType(String) - case pluginDisabled(pluginId: String, pluginName: String) case pluginLoadFailed(pluginId: String?, pluginName: String, reason: String?) case pluginInstallFailed(databaseType: String, reason: String) @@ -70,8 +69,6 @@ enum PluginError: LocalizedError { return String(format: String(localized: "Plugin '%@' has an invalid descriptor: %@"), pluginId, reason) case .unknownDatabaseType(let databaseType): return String(format: String(localized: "TablePro doesn't recognize the database type “%@”."), databaseType) - case .pluginDisabled(_, let pluginName): - return String(format: String(localized: "The %@ plugin is turned off."), pluginName) case .pluginLoadFailed(_, let pluginName, _): return String(format: String(localized: "The %@ plugin could not be loaded."), pluginName) case .pluginInstallFailed(let databaseType, let reason): @@ -92,8 +89,6 @@ enum PluginError: LocalizedError { switch self { case .unknownDatabaseType: return String(localized: "Edit the connection and choose its database type.") - case .pluginDisabled: - return String(localized: "Turn the plugin on to connect.") case .pluginLoadFailed: return String(localized: "Update or reinstall the plugin in Settings > Plugins.") case .pluginInstallFailed: diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index 444493946e..7cf4b0b224 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -218,7 +218,6 @@ extension PluginManager { guard let installed = plugins.first(where: { $0.id == pluginId }) else { return nil } guard installed.source == .userInstalled else { return nil } guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }) else { return nil } - guard registryPlugin.category != .theme else { return nil } return registryPlugin.version.compare(installed.version, options: .numeric) == .orderedDescending ? registryPlugin : nil } diff --git a/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift b/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift index bdf5464575..9ae41c9e7a 100644 --- a/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift +++ b/TablePro/Core/Plugins/PluginManager+DriverAvailability.swift @@ -8,7 +8,6 @@ import TableProPluginKit internal enum DriverUnavailability: Equatable, Sendable { case unknownType - case disabled(pluginId: String, pluginName: String) case failedToLoad(pluginId: String?, pluginName: String, reason: String?) case outdated(reason: String) case notInstalled @@ -27,9 +26,6 @@ extension PluginManager { ) -> DriverUnavailability { let typeId = databaseType.pluginTypeId if let entry = installedEntry(servingTypeId: typeId) { - guard entry.isEnabled else { - return .disabled(pluginId: entry.id, pluginName: entry.name) - } let rejection = rejectedPlugins.first { $0.providedDatabaseTypeIds.contains(typeId) } return .failedToLoad(pluginId: entry.id, pluginName: entry.name, reason: rejection?.reason) } @@ -56,8 +52,6 @@ extension PluginManager { switch driverUnavailability(for: databaseType) { case .unknownType: return .unknownDatabaseType(databaseType.rawValue) - case .disabled(let pluginId, let pluginName): - return .pluginDisabled(pluginId: pluginId, pluginName: pluginName) case .failedToLoad(let pluginId, let pluginName, let reason): return .pluginLoadFailed(pluginId: pluginId, pluginName: pluginName, reason: reason) case .outdated(let reason): diff --git a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift index d4530979e5..f257289bf1 100644 --- a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift +++ b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift @@ -10,47 +10,6 @@ import SwiftUI import TableProPluginKit extension PluginManager { - func setEnabled(_ enabled: Bool, pluginId: String) { - guard let index = plugins.firstIndex(where: { $0.id == pluginId }) else { return } - - plugins[index].isEnabled = enabled - - var disabled = disabledPluginIds - if enabled { - disabled.remove(pluginId) - } else { - disabled.insert(pluginId) - } - disabledPluginIds = disabled - - if enabled { - /// `principalClass` loads the bundle's executable, so enabling is a code-loading path - /// and takes the same gate every other one does. A plugin that fails it stays disabled - /// and is withdrawn rather than published as an installed one that cannot be used. - do { - try assertLoadable(plugins[index].bundle, source: plugins[index].source) - } catch { - Self.logger.error( - "Refusing to enable plugin '\(pluginId, privacy: .public)': failed the load gate: \(error.publicLogShape, privacy: .public)" - ) - plugins[index].isEnabled = false - disabled.insert(pluginId) - disabledPluginIds = disabled - withdrawPlugin(at: plugins[index].url, reason: error) - return - } - if let principalClass = plugins[index].bundle.principalClass as? any TableProPlugin.Type { - let instance = principalClass.init() - registerCapabilities(instance, pluginId: pluginId) - } - } else { - unregisterCapabilities(pluginId: pluginId) - } - - queryBuildingDriverCache.removeAll() - Self.logger.info("Plugin '\(pluginId)' \(enabled ? "enabled" : "disabled")") - } - func uninstallPlugin(id: String) async throws { guard let index = plugins.firstIndex(where: { $0.id == id }) else { throw PluginError.notFound @@ -75,10 +34,6 @@ extension PluginManager { PluginSettingsStorage(pluginId: id).removeAll() - var disabled = disabledPluginIds - disabled.remove(id) - disabledPluginIds = disabled - if stagedUpdates[id] != nil { await discardStagedUpdate(pluginId: id) } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 9d6077762f..62752fd217 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -176,7 +176,7 @@ extension PluginManager { /// All database types with loaded plugins, ordered by display name. var availableDatabaseTypes: [DatabaseType] { var types: [DatabaseType] = [] - for entry in plugins where entry.isEnabled { + for entry in plugins { if let typeId = entry.databaseTypeId { types.append(DatabaseType(rawValue: typeId)) } @@ -757,11 +757,7 @@ extension PluginManager { if let existingEntry = plugins.first(where: { entry in entry.databaseTypeId == pluginTypeId || entry.additionalTypeIds.contains(pluginTypeId) }) { - if !existingEntry.isEnabled { - setEnabled(true, pluginId: existingEntry.id) - } - if driverPlugins[pluginTypeId] != nil { - Self.logger.info("Re-enabled existing plugin '\(existingEntry.name)' for '\(databaseType.rawValue)'") + if driverPlugin(for: databaseType) != nil { return } Self.logger.warning("Plugin '\(existingEntry.id)' exists but driver not registered, reinstalling") diff --git a/TablePro/Core/Plugins/PluginManager+Validation.swift b/TablePro/Core/Plugins/PluginManager+Validation.swift index 02a7f5ac79..61a75d1199 100644 --- a/TablePro/Core/Plugins/PluginManager+Validation.swift +++ b/TablePro/Core/Plugins/PluginManager+Validation.swift @@ -10,15 +10,13 @@ import TableProPluginKit extension PluginManager { func validateDependencies() { let loadedIds = Set(plugins.map(\.id)) - for plugin in plugins where plugin.isEnabled { + for plugin in plugins { guard plugin.bundle.isLoaded else { continue } guard let principalClass = plugin.bundle.principalClass as? any TableProPlugin.Type else { continue } let deps = principalClass.dependencies for dep in deps { if !loadedIds.contains(dep) { Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is not installed") - } else if let depEntry = plugins.first(where: { $0.id == dep }), !depEntry.isEnabled { - Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is disabled") } } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 8f86d29882..43a1acf140 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -134,10 +134,7 @@ final class PluginManager: ObservableObject { /// Still 19, so every plugin already published for the previous release keeps loading. nonisolated static let minimumCompatiblePluginKitVersion = 19 - private static let disabledPluginsKey = "com.TablePro.disabledPlugins" - private static let legacyDisabledPluginsKey = "disabledPlugins" - private let defaults: UserDefaults private let builtInPluginsURL: URL? internal let userPluginsDir: URL @@ -214,11 +211,6 @@ final class PluginManager: ObservableObject { @Published internal(set) var pluginInstances: [String: any TableProPlugin] = [:] - var disabledPluginIds: Set { - get { Set(defaults.stringArray(forKey: Self.disabledPluginsKey) ?? []) } - set { defaults.set(Array(newValue), forKey: Self.disabledPluginsKey) } - } - nonisolated static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager") @Published private var pendingPluginURLs: [(url: URL, source: PluginSource)] = [] @@ -248,7 +240,6 @@ final class PluginManager: ObservableObject { builtInPluginsURL: URL? = Bundle.main.builtInPlugInsURL, userPluginsDir: URL = PluginManager.defaultUserPluginsDir() ) { - self.defaults = userDefaults self.builtInPluginsURL = builtInPluginsURL self.userPluginsDir = userPluginsDir Self.clearLegacyNeedsRestartKey(in: userDefaults) @@ -312,19 +303,9 @@ final class PluginManager: ObservableObject { } } - private func migrateDisabledPluginsKey() { - if let legacy = defaults.stringArray(forKey: Self.legacyDisabledPluginsKey) { - if defaults.stringArray(forKey: Self.disabledPluginsKey) == nil { - defaults.set(legacy, forKey: Self.disabledPluginsKey) - } - defaults.removeObject(forKey: Self.legacyDisabledPluginsKey) - } - } - // MARK: - Loading func loadPlugins() { - migrateDisabledPluginsKey() cleanStaleStagingArtifacts() discoverAllPlugins() @@ -455,7 +436,6 @@ final class PluginManager: ObservableObject { version: version, pluginDescription: pluginDescription, capabilities: capabilities, - isEnabled: !disabledPluginIds.contains(bundleId), databaseTypeId: primaryTypeId, additionalTypeIds: additionalTypeIds, pluginIconName: pluginIconName, @@ -557,7 +537,6 @@ final class PluginManager: ObservableObject { guard !activatedBundleIds.contains(bundleId) else { return } let entry = plugins.first(where: { $0.id == bundleId }) - if let entry, !entry.isEnabled { return } do { try assertLoadable(bundle, source: entry?.source ?? .userInstalled) @@ -582,8 +561,7 @@ final class PluginManager: ObservableObject { validateCapabilityDeclarations(principalClass, pluginId: bundleId) - let isEnabled = entry?.isEnabled ?? false - if isEnabled { + if entry != nil { let instance = principalClass.init() registerCapabilities(instance, pluginId: bundleId) } @@ -744,7 +722,6 @@ final class PluginManager: ObservableObject { let exportType = principalClass as? any ExportFormatPlugin.Type let importType = principalClass as? any ImportFormatPlugin.Type - let disabled = disabledPluginIds let version: String if let declared = Self.bundleShortVersion(at: url) { version = declared @@ -761,7 +738,6 @@ final class PluginManager: ObservableObject { version: version, pluginDescription: principalClass.pluginDescription, capabilities: principalClass.capabilities, - isEnabled: !disabled.contains(bundleId), databaseTypeId: driverType?.databaseTypeId, additionalTypeIds: driverType?.additionalDatabaseTypeIds ?? [], pluginIconName: driverType?.iconName ?? "puzzlepiece", @@ -772,11 +748,7 @@ final class PluginManager: ObservableObject { plugins.append(entry) validateCapabilityDeclarations(principalClass, pluginId: bundleId) - - if entry.isEnabled { - let instance = principalClass.init() - registerCapabilities(instance, pluginId: bundleId) - } + registerCapabilities(principalClass.init(), pluginId: bundleId) Self.logger.info("Loaded plugin '\(entry.name)' v\(entry.version) [\(source == .builtIn ? "built-in" : "user")]") return entry diff --git a/TablePro/Core/Plugins/PluginModels.swift b/TablePro/Core/Plugins/PluginModels.swift index d0bd1faf64..a67501191e 100644 --- a/TablePro/Core/Plugins/PluginModels.swift +++ b/TablePro/Core/Plugins/PluginModels.swift @@ -15,7 +15,6 @@ struct PluginEntry: Identifiable { var version: String let pluginDescription: String let capabilities: [PluginCapability] - var isEnabled: Bool let databaseTypeId: String? let additionalTypeIds: [String] diff --git a/TablePro/Core/Plugins/Registry/DownloadCountService.swift b/TablePro/Core/Plugins/Registry/DownloadCountService.swift deleted file mode 100644 index 655ed3113e..0000000000 --- a/TablePro/Core/Plugins/Registry/DownloadCountService.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// DownloadCountService.swift -// TablePro -// - -import Combine -import Foundation -import os - -@MainActor -final class DownloadCountService: ObservableObject { - static let shared = DownloadCountService() - - @Published private var counts: [String: Int] = [:] - @Published private var lastFetchDate: Date? - private static let cooldown: TimeInterval = 300 // 5 minutes - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "DownloadCountService") - - private static let releasesURL = URL(string: "https://api.github.com/repos/TableProApp/TablePro/releases?per_page=100")! - - private let session: URLSession - - private init() { - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 15 - config.timeoutIntervalForResource = 30 - self.session = URLSession(configuration: config) - } - - // MARK: - Public - - func downloadCount(for pluginId: String) -> Int? { - counts[pluginId] - } - - func fetchCounts(for manifest: RegistryManifest?) async { - guard let manifest else { return } - - if let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < Self.cooldown { - return - } - - do { - let releases = try await fetchReleases() - let pluginReleases = releases.filter { $0.tagName.hasPrefix("plugin-") } - let tagPrefixToPluginId = buildTagPrefixMap(from: manifest) - - var totals: [String: Int] = [:] - for release in pluginReleases { - let tagPrefix = extractTagPrefix(from: release.tagName) - guard let pluginId = tagPrefixToPluginId[tagPrefix] else { continue } - let releaseTotal = release.assets.reduce(0) { $0 + $1.downloadCount } - totals[pluginId, default: 0] += releaseTotal - } - - counts = totals - lastFetchDate = Date() - Self.logger.info("Fetched download counts for \(totals.count) plugin(s)") - } catch { - Self.logger.error("Failed to fetch download counts: \(error.localizedDescription)") - } - } - - // MARK: - GitHub API - - private func fetchReleases() async throws -> [GitHubRelease] { - var request = URLRequest(url: Self.releasesURL) - request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - throw URLError(.badServerResponse) - } - - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - return try decoder.decode([GitHubRelease].self, from: data) - } - - // MARK: - Tag Prefix Mapping - - private func buildTagPrefixMap(from manifest: RegistryManifest) -> [String: String] { - var map: [String: String] = [:] - for plugin in manifest.plugins { - guard let url = plugin.binaries.first?.downloadURL else { continue } - guard let tagComponent = extractTagComponent(from: url) else { continue } - let prefix = extractTagPrefix(from: tagComponent) - map[prefix] = plugin.id - } - return map - } - - private func extractTagComponent(from downloadURL: String) -> String? { - guard let url = URL(string: downloadURL) else { return nil } - let components = url.pathComponents - guard let downloadIndex = components.firstIndex(of: "download"), - downloadIndex + 1 < components.count else { return nil } - return components[downloadIndex + 1] - } - - private func extractTagPrefix(from tag: String) -> String { - guard let range = tag.range(of: #"-v\d"#, options: .regularExpression) else { return tag } - return String(tag[tag.startIndex.. [RegistryPlugin] { - guard let plugins = manifest?.plugins else { return [] } - - var filtered = plugins - - if let category { - filtered = filtered.filter { $0.category == category } - } - - if !query.isEmpty { - let lowercased = query.lowercased() - filtered = filtered.filter { plugin in - plugin.name.lowercased().contains(lowercased) - || plugin.summary.lowercased().contains(lowercased) - || plugin.author.name.lowercased().contains(lowercased) - } - } - - return filtered - } } enum RegistryFetchIntent: Equatable, Sendable { diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift index 440668f4f8..a23a917ab5 100644 --- a/TablePro/Core/Plugins/Registry/RegistryModels.swift +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -133,17 +133,6 @@ extension RegistryPlugin { throw PluginError.noCompatibleBinary } - - // Themes carry no native code, so PluginKit ABI does not apply; match on architecture only. - func resolvedThemeBinary(for arch: PluginArchitecture = .current) throws -> RegistryBinary { - if let match = binaries.first(where: { $0.architecture == arch }) { - return match - } - if let any = binaries.first { - return any - } - throw PluginError.noCompatibleBinary - } } struct RegistryAuthor: Codable, Sendable { @@ -151,23 +140,16 @@ struct RegistryAuthor: Codable, Sendable { let url: String? } -enum RegistryCategory: String, Codable, Sendable, CaseIterable, Identifiable { +enum RegistryCategory: String, Codable, Sendable { case databaseDriver = "database-driver" case exportFormat = "export-format" case importFormat = "import-format" - case theme = "theme" case other = "other" - var id: String { rawValue } - - var displayName: String { - switch self { - case .databaseDriver: String(localized: "Database Drivers") - case .exportFormat: String(localized: "Export Formats") - case .importFormat: String(localized: "Import Formats") - case .theme: String(localized: "Themes") - case .other: String(localized: "Other") - } + // One entry with a category this app does not know must not fail the whole manifest. + init(from decoder: any Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + self = RegistryCategory(rawValue: rawValue) ?? .other } } diff --git a/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift b/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift index 3014705815..dfbfaae2a5 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionRecoveryPerformer.swift @@ -18,15 +18,11 @@ internal enum ConnectionRecoveryPerformer { internal static func perform( _ action: ConnectionRecoveryAction, - for connection: DatabaseConnection, - retry: @escaping @MainActor () -> Void + for connection: DatabaseConnection ) { switch action { case .installPlugin: WelcomeRouter.shared.routePluginInstall(connection) - case .enablePlugin(let pluginId): - PluginManager.shared.setEnabled(true, pluginId: pluginId) - retry() case .openPluginSettings(let pluginId): PluginsSettingsNavigation.shared.reveal(pluginId: pluginId) WindowOpener.shared.openSettings(tab: .plugins) diff --git a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift index 398c968f5a..fc103b9ec2 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchIntentRouter.swift @@ -97,9 +97,7 @@ internal final class LaunchIntentRouter { recoveryTitle: action.title, window: NSApp.keyWindow ) { - ConnectionRecoveryPerformer.perform(action, for: connection) { - Task { await LaunchIntentRouter.shared.route(intent) } - } + ConnectionRecoveryPerformer.perform(action, for: connection) } return true } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift index 525a0385df..b1ae179a59 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+Connection.swift @@ -146,9 +146,7 @@ internal extension MainSplitViewController { return } guard let connection = workspaces.workspace(for: connectionId)?.connection else { return } - ConnectionRecoveryPerformer.perform(action, for: connection) { [weak self] in - self?.reconnectWorkspace(connectionId) - } + ConnectionRecoveryPerformer.perform(action, for: connection) } private func connect(_ connection: DatabaseConnection, cancellingPrevious: Bool) { diff --git a/TablePro/Models/Connection/ConnectionFailureClassifier.swift b/TablePro/Models/Connection/ConnectionFailureClassifier.swift index 0cec29bb4f..b8a701d365 100644 --- a/TablePro/Models/Connection/ConnectionFailureClassifier.swift +++ b/TablePro/Models/Connection/ConnectionFailureClassifier.swift @@ -53,8 +53,6 @@ internal enum ConnectionFailureClassifier { switch pluginError { case .pluginNotInstalled: return .installPlugin - case .pluginDisabled(let pluginId, _): - return .enablePlugin(pluginId: pluginId) case .pluginLoadFailed(let pluginId, _, _): return .openPluginSettings(pluginId: pluginId) case .pluginUpdateUnavailable: diff --git a/TablePro/Models/Connection/ConnectionRecoveryAction.swift b/TablePro/Models/Connection/ConnectionRecoveryAction.swift index b331c088ee..d5da260dc2 100644 --- a/TablePro/Models/Connection/ConnectionRecoveryAction.swift +++ b/TablePro/Models/Connection/ConnectionRecoveryAction.swift @@ -7,7 +7,6 @@ import Foundation internal enum ConnectionRecoveryAction: Equatable, Sendable { case installPlugin - case enablePlugin(pluginId: String) case openPluginSettings(pluginId: String?) case editConnection @@ -15,8 +14,6 @@ internal enum ConnectionRecoveryAction: Equatable, Sendable { switch self { case .installPlugin: return String(localized: "Install Plugin…") - case .enablePlugin: - return String(localized: "Enable Plugin") case .openPluginSettings: return String(localized: "Open Plugin Settings") case .editConnection: @@ -26,7 +23,7 @@ internal enum ConnectionRecoveryAction: Equatable, Sendable { internal var symbolName: String { switch self { - case .installPlugin, .enablePlugin, .openPluginSettings: + case .installPlugin, .openPluginSettings: return "puzzlepiece.extension" case .editConnection: return "exclamationmark.triangle" diff --git a/TablePro/Models/Settings/LegacyThemeFonts.swift b/TablePro/Models/Settings/LegacyThemeFonts.swift index 4a767deb2f..2bb3fdfa75 100644 --- a/TablePro/Models/Settings/LegacyThemeFonts.swift +++ b/TablePro/Models/Settings/LegacyThemeFonts.swift @@ -30,8 +30,7 @@ internal enum LegacyThemeFonts { } private static func fonts(forThemeId id: String) -> Document.Fonts? { - let directories = [userThemesDirectory, userThemesDirectory.appendingPathComponent("Registry")] - + (Bundle.main.resourceURL.map { [$0] } ?? []) + let directories = [userThemesDirectory] + (Bundle.main.resourceURL.map { [$0] } ?? []) for directory in directories { let url = directory.appendingPathComponent("\(id).json", isDirectory: false) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2e3e8089ef..fe000a54a5 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3243,86 +3243,6 @@ } } }, - "%@ download" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ téléchargement" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@회 다운로드" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ indirme" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ lượt tải" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 次下载" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 次下載" - } - } - } - }, - "%@ downloads" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ téléchargements" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@회 다운로드" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ indirme" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ lượt tải" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 次下载" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 次下載" - } - } - } - }, "%@ duplicate rows" : { "localizations" : { "fr" : { @@ -28284,46 +28204,6 @@ } } }, - "Author" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auteur" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "작성자" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yazar" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tác giả" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "作者" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "作者" - } - } - } - }, "Auto" : { "localizations" : { "fr" : { @@ -56854,46 +56734,6 @@ } } }, - "Database Drivers" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pilotes de base de données" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "데이터베이스 드라이버" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Veritabanı Sürücüleri" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Trình điều khiển cơ sở dữ liệu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "数据库驱动" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "資料庫驅動程式" - } - } - } - }, "Database engine" : { "localizations" : { "fr" : { @@ -64882,46 +64722,6 @@ } } }, - "Downloads" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Téléchargements" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "다운로드" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İndirmeler" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lượt tải" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "下载次数" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "下載次數" - } - } - } - }, "Downtrend" : { "extractionState" : "manual", "generatesSymbol" : false @@ -69267,46 +69067,6 @@ } } }, - "Enable %@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activer %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ 활성화" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "%@ Etkinleştir" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bật %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "启用 %@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "啟用 %@" - } - } - } - }, "Enable AI Features" : { "localizations" : { "fr" : { @@ -69548,16 +69308,6 @@ } } }, - "Enable Plugin" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activer le plugin" - } - } - } - }, "Enable preview tabs" : { "localizations" : { "fr" : { @@ -75729,46 +75479,6 @@ } } }, - "Export Formats" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Formats d’exportation" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "내보내기 형식" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dışa Aktarma Biçimleri" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Định dạng xuất" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "导出格式" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "匯出格式" - } - } - } - }, "Export multiple tables" : { "localizations" : { "fr" : { @@ -77701,46 +77411,6 @@ } } }, - "Failed to Load" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Impossible de charger" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "불러오지 못했습니다" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yükleme Başarısız" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tải thất bại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "加载失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "載入失敗" - } - } - } - }, "Failed to load %@" : { "localizations" : { "fr" : { @@ -89622,46 +89292,6 @@ } } }, - "Import Formats" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Formats d’importation" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "가져오기 형식" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İçe Aktarma Biçimleri" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Định dạng nhập" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "导入格式" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "匯入格式" - } - } - } - }, "Import from %@" : { "localizations" : { "fr" : { @@ -93164,46 +92794,6 @@ } } }, - "Install Plugin" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installer le plugin" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "플러그인 설치" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eklentiyi Yükle" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cài đặt Plugin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "安装插件" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "安裝外掛" - } - } - } - }, "Install plugin from file" : { "localizations" : { "fr" : { @@ -93300,86 +92890,6 @@ } } }, - "Install Theme" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installer le thème" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "테마 설치" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temayı Yükle" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cài đặt chủ đề" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "安装主题" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "安裝主題" - } - } - } - }, - "Installation Failed" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Échec de l’installation" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "설치 실패" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yükleme Başarısız" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cài đặt thất bại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "安装失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "安裝失敗" - } - } - } - }, "Installed" : { "localizations" : { "fr" : { @@ -114366,45 +113876,8 @@ } } }, - "No export formats available. Enable export plugins in Settings > Plugins." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucun format d’exportation disponible. Activez des plugins d’exportation dans Réglages > Plugins." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "사용 가능한 내보내기 형식이 없습니다. 설정 > 플러그인에서 내보내기 플러그인을 활성화하십시오." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kullanılabilir dışa aktarma biçimi yok. Ayarlar > Eklentiler bölümünden dışa aktarma eklentilerini etkinleştirin." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Không có định dạng xuất khả dụng. Bật plugin xuất trong Cài đặt > Plugin." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "无可用的导出格式。请在设置 > 插件中启用导出插件。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "沒有可用的匯出格式。請在「設定 > 外掛」中啟用匯出外掛。" - } - } - } + "No export formats available." : { + }, "No Extensions" : { "localizations" : { @@ -126112,46 +125585,6 @@ } } }, - "Operation Failed" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Échec de l’opération" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "작업 실패" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "İşlem Başarısız" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thao tác thất bại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "操作失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "操作失敗" - } - } - } - }, "Operation name from list_maintenance_operations" : { "localizations" : { "fr" : { @@ -142087,46 +141520,6 @@ } } }, - "Refresh plugin list" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Actualiser la liste des plugins" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "플러그인 목록 새로 고침" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Eklenti listesini yenile" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Làm mới danh sách plugin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "刷新插件列表" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "重新整理外掛清單" - } - } - } - }, "Refresh the materialized view “%@”?" : { "localizations" : { "fr" : { @@ -142329,46 +141722,6 @@ } } }, - "Registry" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Registre" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "레지스트리" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Registry" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kho plugin" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "插件仓库" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "外掛註冊" - } - } - } - }, "Regular Expression" : { "localizations" : { "fr" : { @@ -145817,46 +145170,6 @@ } } }, - "Requires" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nécessite" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "필요 버전" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Requires" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Yêu cầu" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "需要" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "需要" - } - } - } - }, "Reserved Shortcut" : { "localizations" : { "fr" : { @@ -148193,46 +147506,6 @@ } } }, - "Retry Install" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Réessayer l’installation" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "설치 재시도" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kurulumu Yeniden Dene" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thử cài đặt lại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "重试安装" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "重試安裝" - } - } - } - }, "Retry Update" : { "localizations" : { "fr" : { @@ -156583,46 +155856,6 @@ } } }, - "Search…" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Rechercher…" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "검색…" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ara…" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tìm kiếm…" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "搜索…" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "搜尋…" - } - } - } - }, "Searching…" : { "localizations" : { "fr" : { @@ -163270,46 +162503,6 @@ } } }, - "Showing saved plugin list. %@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Affichage de la liste de plugins enregistrée. %@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "저장된 플러그인 목록을 표시합니다. %@" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kaydedilmiş eklenti listesi gösteriliyor. %@" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Đang hiển thị danh sách plugin đã lưu. %@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在显示已保存的插件列表。%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在顯示已儲存的外掛清單。%@" - } - } - } - }, "Showing the %d most recent commits." : { "localizations" : { "fr" : { @@ -180119,16 +179312,6 @@ } } }, - "The %@ plugin is turned off." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Le plugin %@ est désactivé." - } - } - } - }, "The %@ request must be a JSON object" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -189960,46 +189143,6 @@ }, "Theme format %1$d was made for a newer TablePro. This version reads format %2$d." : { - }, - "Theme Update Failed" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Échec de la mise à jour du thème" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "테마 업데이트 실패" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tema Güncellemesi Başarısız Oldu" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cập nhật theme thất bại" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "主题更新失败" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "佈景主題更新失敗" - } - } - } }, "Theme:" : { "extractionState" : "stale", @@ -190042,46 +189185,6 @@ } } }, - "Themes" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thèmes" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "테마" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Themes" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Giao diện" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "主题" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "佈景主題" - } - } - } - }, "There are no connections to publish." : { "localizations" : { "fr" : { @@ -200154,16 +199257,6 @@ } } }, - "Turn the plugin on to connect." : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Activez le plugin pour vous connecter." - } - } - } - }, "Two columns are both named “%@”. Rename one, then save again." : { "localizations" : { "fr" : { @@ -203469,46 +202562,6 @@ } } }, - "Update to v%@" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mettre à jour vers v%@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "버전 v%@ 업데이트" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@ sürümüne güncelle" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Cập nhật lên v%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "更新到 v%@" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "更新至 v%@" - } - } - } - }, "Updated" : { "localizations" : { "fr" : { @@ -205733,46 +204786,6 @@ } } }, - "v%@+" : { - "localizations" : { - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@+" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@+" - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@+" - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@+" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@+" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "v%@+" - } - } - } - }, "Validation Failed" : { "localizations" : { "fr" : { diff --git a/TablePro/Theme/RegistryThemeMeta.swift b/TablePro/Theme/RegistryThemeMeta.swift deleted file mode 100644 index 536ced7c91..0000000000 --- a/TablePro/Theme/RegistryThemeMeta.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -internal struct RegistryThemeMeta: Codable { - var installed: [InstalledRegistryTheme] - - init(installed: [InstalledRegistryTheme] = []) { - self.installed = installed - } -} - -internal struct InstalledRegistryTheme: Codable, Identifiable { - let id: String - let registryPluginId: String - let version: String - let installedDate: Date -} diff --git a/TablePro/Theme/ThemeCatalog.swift b/TablePro/Theme/ThemeCatalog.swift index cb957a85db..cc01eea962 100644 --- a/TablePro/Theme/ThemeCatalog.swift +++ b/TablePro/Theme/ThemeCatalog.swift @@ -17,16 +17,7 @@ internal struct RejectedThemeRecord: Equatable, Sendable { internal enum ThemeOrigin: Sendable { case bundle - case registry case user - - internal var allowedPrefix: String? { - switch self { - case .bundle: return ThemeDefinition.builtInPrefix - case .registry: return ThemeDefinition.registryPrefix - case .user: return nil - } - } } /// Owns the list. Loading is off the main actor; every mutation updates the in-memory list @@ -78,24 +69,13 @@ internal final class ThemeCatalog: ObservableObject { } internal func delete(id: String) throws { - guard !id.hasPrefix(ThemeDefinition.builtInPrefix), !id.hasPrefix(ThemeDefinition.registryPrefix) else { + guard !id.hasPrefix(ThemeDefinition.builtInPrefix) else { throw ThemeLoadError.reservedIdentifier(id) } try Self.remove(id: id, from: Self.userDirectory) themes.removeAll { $0.id == id } } - internal func saveRegistryTheme(_ theme: ThemeDefinition) throws { - try Self.write(theme, to: Self.registryDirectory) - upsert(theme) - } - - internal func deleteRegistryTheme(id: String) throws { - try Self.remove(id: id, from: Self.registryDirectory) - themes.removeAll { $0.id == id } - rejected.removeAll { $0.fileName == "\(id).json" } - } - internal func importTheme(from url: URL) throws -> ThemeDefinition { let document = try ThemeDocument(data: try Data(contentsOf: url)) var theme = document.resolved() @@ -113,27 +93,6 @@ internal final class ThemeCatalog: ObservableObject { try ThemeEncoder.data(for: theme).write(to: url, options: .atomic) } - internal func loadRegistryMeta() -> RegistryThemeMeta { - let url = Self.registryMetaURL - guard FileManager.default.fileExists(atPath: url.path) else { return RegistryThemeMeta() } - do { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try decoder.decode(RegistryThemeMeta.self, from: try Data(contentsOf: url)) - } catch { - Self.logger.error("Failed to load registry meta: \(error)") - return RegistryThemeMeta() - } - } - - internal func saveRegistryMeta(_ meta: RegistryThemeMeta) throws { - Self.ensureDirectory(Self.registryDirectory) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - try encoder.encode(meta).write(to: Self.registryMetaURL, options: .atomic) - } - private func containsTheme(id: String) -> Bool { themes.contains { $0.id == id } } @@ -152,12 +111,6 @@ internal final class ThemeCatalog: ObservableObject { AppStorageEnvironment.shared.applicationSupportRoot .appendingPathComponent("TablePro/Themes", isDirectory: true) - nonisolated private static let registryDirectory: URL = - userDirectory.appendingPathComponent("Registry", isDirectory: true) - - nonisolated private static let registryMetaURL: URL = - registryDirectory.appendingPathComponent("registry-meta.json") - // MARK: - Disk nonisolated private static func loadContents() -> ThemeCatalogContents { @@ -165,11 +118,9 @@ internal final class ThemeCatalog: ObservableObject { var rejected: [RejectedThemeRecord] = [] ensureDirectory(userDirectory) - ensureDirectory(registryDirectory) let sources: [(URL, ThemeOrigin)] = [ (Bundle.main.resourceURL, .bundle), - (registryDirectory, .registry), (userDirectory, .user), ].compactMap { url, source in url.map { ($0, source) } } @@ -206,7 +157,7 @@ internal final class ThemeCatalog: ObservableObject { let files: [URL] do { files = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "json" && $0.lastPathComponent != "registry-meta.json" } + .filter { $0.pathExtension == "json" } } catch { logger.error("Failed to list \(directory.lastPathComponent): \(error)") return ([], []) @@ -240,19 +191,14 @@ internal final class ThemeCatalog: ObservableObject { return (themes, rejected) } - /// A reserved prefix is accepted only from the directory that owns it. A file dropped into the - /// user folder claiming `tablepro.` or `registry.` would otherwise shadow the real theme, be - /// read-only because the prefix says built-in, and be impossible to delete. + /// A user file claiming `tablepro.` would shadow the built-in theme, read as read-only and be + /// impossible to delete. nonisolated private static func validate(identifier: String, from source: ThemeOrigin) throws { guard ThemeIdentifier.isValid(identifier) else { throw ThemeLoadError.invalidIdentifier(identifier) } - - let reserved = [ThemeDefinition.builtInPrefix, ThemeDefinition.registryPrefix] - for prefix in reserved where identifier.hasPrefix(prefix) { - guard source.allowedPrefix == prefix else { - throw ThemeLoadError.reservedIdentifier(identifier) - } + guard source == .bundle || !identifier.hasPrefix(ThemeDefinition.builtInPrefix) else { + throw ThemeLoadError.reservedIdentifier(identifier) } } diff --git a/TablePro/Theme/ThemeDefinition.swift b/TablePro/Theme/ThemeDefinition.swift index e0904f6f61..8b90770250 100644 --- a/TablePro/Theme/ThemeDefinition.swift +++ b/TablePro/Theme/ThemeDefinition.swift @@ -75,12 +75,10 @@ internal struct ThemeDefinition: Identifiable, Equatable, Sendable { var status: StatusThemeColors internal static let builtInPrefix = "tablepro." - internal static let registryPrefix = "registry." internal static let userPrefix = "user." internal var isBuiltIn: Bool { id.hasPrefix(Self.builtInPrefix) } - internal var isRegistry: Bool { id.hasPrefix(Self.registryPrefix) } - internal var isEditable: Bool { !isBuiltIn && !isRegistry } + internal var isEditable: Bool { !isBuiltIn } } /// The single registry every slot goes through. A slot exists here because a call site reads it, diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift deleted file mode 100644 index 37be2149ae..0000000000 --- a/TablePro/Theme/ThemeRegistryInstaller.swift +++ /dev/null @@ -1,278 +0,0 @@ -// -// ThemeRegistryInstaller.swift -// TablePro -// -// Handles install/uninstall/update of themes from the plugin registry. -// Themes are pure JSON (no executable code, no .tableplugin bundles). -// - -import Combine -import CryptoKit -import Foundation -import os - -@MainActor -internal final class ThemeRegistryInstaller: ObservableObject { - static let shared = ThemeRegistryInstaller() - - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ThemeRegistryInstaller") - - private init() {} - - // MARK: - Install - - func install( - _ plugin: RegistryPlugin, - progress: @escaping @MainActor @Sendable (Double) -> Void - ) async throws { - guard !isInstalled(plugin.id) else { - throw PluginError.pluginConflict(existingName: plugin.name) - } - - let decodedThemes = try await downloadAndDecode(plugin, progress: progress) - - _ = try? removeRegistryFiles(for: plugin.id) - - var installedThemes: [InstalledRegistryTheme] = [] - - for theme in decodedThemes { - try ThemeCatalog.shared.saveRegistryTheme(theme) - - installedThemes.append(InstalledRegistryTheme( - id: theme.id, - registryPluginId: plugin.id, - version: plugin.version, - installedDate: Date() - )) - } - - var meta = ThemeCatalog.shared.loadRegistryMeta() - meta.installed.append(contentsOf: installedThemes) - try ThemeCatalog.shared.saveRegistryMeta(meta) - - ThemeCatalog.shared.reloadSynchronously() - reactivate() - progress(1.0) - - Self.logger.info("Installed \(installedThemes.count) theme(s) from registry plugin: \(plugin.id)") - } - - // MARK: - Uninstall - - func uninstall(registryPluginId: String) throws { - _ = try removeRegistryFiles(for: registryPluginId) - - ThemeCatalog.shared.reloadSynchronously() - reactivate() - - Self.logger.info("Uninstalled registry themes for plugin: \(registryPluginId)") - } - - // MARK: - Update - - func update( - _ plugin: RegistryPlugin, - progress: @escaping @MainActor @Sendable (Double) -> Void - ) async throws { - let stagedThemes = try await downloadAndDecode(plugin, progress: progress) - - // Remove old files without triggering theme reload or fallback - _ = try removeRegistryFiles(for: plugin.id) - - var installedThemes: [InstalledRegistryTheme] = [] - for theme in stagedThemes { - try ThemeCatalog.shared.saveRegistryTheme(theme) - installedThemes.append(InstalledRegistryTheme( - id: theme.id, - registryPluginId: plugin.id, - version: plugin.version, - installedDate: Date() - )) - } - - var meta = ThemeCatalog.shared.loadRegistryMeta() - meta.installed.append(contentsOf: installedThemes) - try ThemeCatalog.shared.saveRegistryMeta(meta) - - ThemeCatalog.shared.reloadSynchronously() - reactivate() - - Self.logger.info("Updated \(installedThemes.count) theme(s) for registry plugin: \(plugin.id)") - } - - /// Removes meta entries and files for a registry plugin. Returns removed theme IDs. - /// Does NOT reload ThemeEngine or trigger fallback — callers manage that. - @discardableResult - private func removeRegistryFiles(for registryPluginId: String) throws -> Set { - var meta = ThemeCatalog.shared.loadRegistryMeta() - let themesToRemove = meta.installed.filter { $0.registryPluginId == registryPluginId } - let removedIds = Set(themesToRemove.map(\.id)) - - meta.installed.removeAll { $0.registryPluginId == registryPluginId } - try ThemeCatalog.shared.saveRegistryMeta(meta) - - for entry in themesToRemove { - do { - try ThemeCatalog.shared.deleteRegistryTheme(id: entry.id) - } catch { - Self.logger.warning("Failed to delete registry theme file \(entry.id): \(error)") - } - } - - return removedIds - } - - // MARK: - Query - - func isInstalled(_ registryPluginId: String) -> Bool { - let entries = ThemeCatalog.shared.loadRegistryMeta().installed - .filter { $0.registryPluginId == registryPluginId } - - guard !entries.isEmpty else { return false } - return entries.allSatisfy { ThemeCatalog.shared.theme(id: $0.id) != nil } - } - - func installedVersion(for registryPluginId: String) -> String? { - let meta = ThemeCatalog.shared.loadRegistryMeta() - return meta.installed.first { $0.registryPluginId == registryPluginId }?.version - } - - func availableUpdates(manifest: RegistryManifest) -> [RegistryPlugin] { - let meta = ThemeCatalog.shared.loadRegistryMeta() - let installedVersions = Dictionary( - meta.installed.map { ($0.registryPluginId, $0.version) }, - uniquingKeysWith: { first, _ in first } - ) - - return manifest.plugins.filter { plugin in - guard plugin.category == .theme, - let installed = installedVersions[plugin.id] else { return false } - return plugin.version.compare(installed, options: .numeric) == .orderedDescending - } - } - - // MARK: - Download & Decode - - private func downloadAndDecode( - _ plugin: RegistryPlugin, - progress: @escaping @MainActor @Sendable (Double) -> Void - ) async throws -> [ThemeDefinition] { - if let minAppVersion = plugin.minAppVersion { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" - if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { - throw PluginError.incompatibleWithCurrentApp(minimumRequired: minAppVersion) - } - } - - let resolved = try plugin.resolvedThemeBinary(for: .current) - - guard let downloadURL = URL(string: resolved.downloadURL) else { - throw PluginError.downloadFailed("Invalid download URL") - } - - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - - defer { - try? FileManager.default.removeItem(at: tempDir) - } - - let session = RegistryClient.shared.session - let (tempDownloadURL, response) = try await session.download(from: downloadURL) - - guard let httpResponse = response as? HTTPURLResponse, - (200 ... 299).contains(httpResponse.statusCode) else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0 - throw PluginError.downloadFailed("HTTP \(statusCode)") - } - - progress(0.5) - - let downloadedData = try Data(contentsOf: tempDownloadURL) - let digest = SHA256.hash(data: downloadedData) - let hexChecksum = digest.hexEncoded - - if hexChecksum != resolved.sha256.lowercased() { - throw PluginError.checksumMismatch - } - - progress(0.7) - - let extractDir = tempDir.appendingPathComponent("extracted", isDirectory: true) - try FileManager.default.createDirectory(at: extractDir, withIntermediateDirectories: true) - - let zipPath = tempDir.appendingPathComponent("theme.zip") - try FileManager.default.moveItem(at: tempDownloadURL, to: zipPath) - - try await Task.detached(priority: .userInitiated) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.arguments = ["-xk", zipPath.path, extractDir.path] - try process.run() - process.waitUntilExit() - guard process.terminationStatus == 0 else { - throw PluginError.installFailed("Failed to extract theme archive") - } - }.value - - PluginInstaller.stripQuarantine(at: extractDir) - - let jsonFiles = try findJsonFiles(in: extractDir) - guard !jsonFiles.isEmpty else { - throw PluginError.installFailed("No theme files found in archive") - } - - for jsonFile in jsonFiles { - PluginInstaller.stripQuarantine(at: jsonFile) - } - - progress(0.9) - - var decodedThemes: [ThemeDefinition] = [] - - for jsonURL in jsonFiles { - let document = try ThemeDocument(data: try Data(contentsOf: jsonURL)) - var theme = document.resolved() - theme.id = "\(ThemeDefinition.registryPrefix)\(plugin.id).\(theme.id)" - decodedThemes.append(theme) - } - - let ids = decodedThemes.map(\.id) - guard ids.count == Set(ids).count else { - throw PluginError.installFailed("Theme pack contains duplicate IDs after namespace rewrite") - } - - return decodedThemes - } - - // MARK: - Helpers - - private func reactivate() { - let appearance = AppSettingsManager.shared.appearance - ThemeEngine.shared.reapply( - lightThemeId: appearance.preferredLightThemeId, - darkThemeId: appearance.preferredDarkThemeId - ) - } - - private func findJsonFiles(in directory: URL) throws -> [URL] { - var results: [URL] = [] - let fm = FileManager.default - - guard let enumerator = fm.enumerator( - at: directory, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles] - ) else { return [] } - - for case let fileURL as URL in enumerator { - if fileURL.pathExtension.lowercased() == "json" && - fileURL.lastPathComponent != "registry-meta.json" { - results.append(fileURL) - } - } - - return results - } -} diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 17cf3b169d..cec213b5b8 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -699,9 +699,7 @@ final class WelcomeViewModel: ObservableObject { func performConnectionErrorRecovery() { guard let recovery = connectionErrorRecovery else { return } dismissConnectionError() - ConnectionRecoveryPerformer.perform(recovery.action, for: recovery.connection) { [weak self] in - self?.connectToDatabase(recovery.connection) - } + ConnectionRecoveryPerformer.perform(recovery.action, for: recovery.connection) } func dismissConnectionError() { diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index f11e2b5e3c..5ecd8f6740 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -590,10 +590,8 @@ final class ConnectionFormCoordinator: ObservableObject { recoverySuggestion: info.recoverySuggestion, recoveryTitle: action.title, window: window - ) { [weak self] in - ConnectionRecoveryPerformer.perform(action, for: connection) { - self?.test() - } + ) { + ConnectionRecoveryPerformer.perform(action, for: connection) } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 701f7580d7..e59d960158 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -428,7 +428,7 @@ struct ExportDialog: View { if availableFormats.isEmpty { HStack { Spacer() - Text("No export formats available. Enable export plugins in Settings > Plugins.") + Text("No export formats available.") .font(.subheadline) .foregroundStyle(.secondary) Spacer() diff --git a/TablePro/Views/Settings/Appearance/ThemeListRowView.swift b/TablePro/Views/Settings/Appearance/ThemeListRowView.swift index 2fcf072ffc..e9db3939da 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListRowView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListRowView.swift @@ -12,11 +12,7 @@ internal struct ThemeListRowView: View { .font(.callout) .lineLimit(1) - Text(theme.isBuiltIn - ? String(localized: "Built-in") - : theme.isRegistry - ? String(localized: "Registry") - : String(localized: "Custom")) + Text(theme.isBuiltIn ? String(localized: "Built-in") : String(localized: "Custom")) .font(.caption) .foregroundStyle(.secondary) } diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index ba66ede758..e3d16481e8 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -22,10 +22,6 @@ internal struct ThemeListView: View { eligible(catalog.themes.filter(\.isBuiltIn)) } - private var registryThemes: [ThemeDefinition] { - eligible(catalog.themes.filter(\.isRegistry)) - } - private var customThemes: [ThemeDefinition] { eligible(catalog.themes.filter(\.isEditable)) } @@ -51,14 +47,6 @@ internal struct ThemeListView: View { } } - if !registryThemes.isEmpty { - Section("Registry") { - ForEach(registryThemes) { theme in - ThemeListRowView(theme: theme).tag(theme.id) - } - } - } - if !customThemes.isEmpty { Section("Custom") { ForEach(customThemes) { theme in @@ -146,10 +134,6 @@ internal struct ThemeListView: View { Menu { Button(String(localized: "Duplicate")) { duplicateSelectedTheme() } Button(String(localized: "Export…")) { exportSelectedTheme() } - if selectedTheme?.isRegistry == true { - Divider() - Button(String(localized: "Uninstall"), role: .destructive) { uninstallRegistryTheme() } - } } label: { Image(systemName: "gearshape") .frame(width: 24, height: 24) @@ -182,17 +166,6 @@ internal struct ThemeListView: View { perform { try catalog.delete(id: theme.id) } then: { selectedThemeId = fallbackThemeId } } - private func uninstallRegistryTheme() { - guard let theme = selectedTheme, theme.isRegistry else { return } - guard let entry = catalog.loadRegistryMeta().installed.first(where: { $0.id == theme.id }) else { return } - - perform { - try ThemeRegistryInstaller.shared.uninstall(registryPluginId: entry.registryPluginId) - } then: { - selectedThemeId = fallbackThemeId - } - } - private func exportSelectedTheme() { guard let theme = selectedTheme, let window = AlertHelper.resolveWindow(nil) else { return } diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift deleted file mode 100644 index 2b11f3fd67..0000000000 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ /dev/null @@ -1,352 +0,0 @@ -// -// BrowsePluginsView.swift -// TablePro -// - -import SwiftUI - -struct BrowsePluginsView: View { - @ObservedObject private var registryClient = RegistryClient.shared - @ObservedObject private var pluginManager = PluginManager.shared - @ObservedObject private var installTracker = PluginInstallTracker.shared - @ObservedObject private var downloadCountService = DownloadCountService.shared - - @State private var searchText = "" - @State private var selectedCategory: RegistryCategory? - @State private var selectedPluginId: String? - @State private var showErrorAlert = false - @State private var errorTitle = String(localized: "Operation Failed") - @State private var errorMessage = "" - - private var selectedRegistryPlugin: RegistryPlugin? { - guard let selectedPluginId else { return nil } - return registryClient.manifest?.plugins.first { $0.id == selectedPluginId } - } - - var body: some View { - mainContent - .task { - await registryClient.ensureManifest(.ifStale) - await downloadCountService.fetchCounts(for: registryClient.manifest) - } - .alert(errorTitle, isPresented: $showErrorAlert) { - Button("OK") {} - } message: { - Text(errorMessage) - } - .onChange(of: searchText) { _ in - clearSelectionIfNeeded() - } - .onChange(of: selectedCategory) { _ in - clearSelectionIfNeeded() - } - } - - // MARK: - Main Content - - @ViewBuilder - private var mainContent: some View { - if registryClient.manifest != nil { - loadedContent - } else { - switch registryClient.fetchState { - case .idle, .loading, .loaded, .loadedFromCache: - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - - case .failed(let message): - UnavailableStateView { - Label("Failed to Load", systemImage: "wifi.slash") - } description: { - Text(message) - } actions: { - Button("Try Again") { - refreshRegistry() - } - .buttonStyle(.bordered) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - } - - private var loadedContent: some View { - let plugins = registryClient.search(query: searchText, category: selectedCategory) - return HSplitView { - VStack(spacing: 0) { - HStack(spacing: 6) { - NativeSearchField(text: $searchText, placeholder: String(localized: "Search…")) - Picker("Category", selection: $selectedCategory) { - Text("All").tag(RegistryCategory?.none) - ForEach(RegistryCategory.allCases) { category in - Text(category.displayName).tag(RegistryCategory?.some(category)) - } - } - .labelsHidden() - .fixedSize() - - Button { - refreshRegistry() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.borderless) - .disabled(registryClient.fetchState == .loading) - .help("Refresh plugin list") - .accessibilityLabel(String(localized: "Refresh plugin list")) - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - - if case .loadedFromCache(let reason) = registryClient.fetchState { - staleManifestBanner(reason) - } - - if plugins.isEmpty { - UnavailableStateView.search(text: searchText) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(plugins, selection: $selectedPluginId) { plugin in - browseRow(plugin) - .tag(plugin.id) - } - .listStyle(.inset) - } - } - .frame(minWidth: 200, idealWidth: 240, maxWidth: 280) - - detailContent - .frame(minWidth: 340) - } - } - - private func staleManifestBanner(_ reason: String) -> some View { - HStack(spacing: 6) { - Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.yellow) - Text(String(format: String(localized: "Showing saved plugin list. %@"), reason)) - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - - private func refreshRegistry() { - Task { - await registryClient.ensureManifest(.mustBeCurrent) - await downloadCountService.fetchCounts(for: registryClient.manifest) - } - } - - // MARK: - Browse Row - - @ViewBuilder - private func browseRow(_ plugin: RegistryPlugin) -> some View { - HStack(spacing: 8) { - PluginIconView(name: plugin.iconName ?? "puzzlepiece") - .font(.title3) - .frame(width: 24, height: 24) - .foregroundStyle(.secondary) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(plugin.name) - .lineLimit(1) - if plugin.isVerified { - Image(systemName: "checkmark.seal.fill") - .foregroundStyle(.blue) - .font(.caption2) - } - } - - HStack(spacing: 4) { - Text("v\(plugin.version)") - Text("·") - Text(plugin.author.name) - .lineLimit(1) - if let count = downloadCountService.downloadCount(for: plugin.id) { - Text("·") - Text("\(Image(systemName: "arrow.down.circle")) \(formattedCount(count))") - } - } - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - rowStatusBadge(for: plugin) - } - .padding(.vertical, 2) - } - - // MARK: - Row Status Badge - - @ViewBuilder - private func rowStatusBadge(for plugin: RegistryPlugin) -> some View { - 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) - .accessibilityLabel(String(localized: "Restart to activate")) - case .completed: - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.caption) - .accessibilityLabel(String(localized: "Installed")) - case .failed: - 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) - } - return "\(count)" - } - - // MARK: - Detail - - @ViewBuilder - private var detailContent: some View { - if let selectedPlugin = selectedRegistryPlugin { - RegistryPluginDetailView( - plugin: selectedPlugin, - isInstalled: isPluginInstalled(selectedPlugin.id), - hasUpdate: hasUpdate(for: selectedPlugin), - installProgress: installTracker.state(for: selectedPlugin.id), - downloadCount: downloadCountService.downloadCount(for: selectedPlugin.id), - onInstall: { installPlugin(selectedPlugin) }, - onUpdate: { updatePlugin(selectedPlugin) } - ) - } else { - VStack(spacing: 8) { - Image(systemName: "puzzlepiece.extension") - .font(.title) - .foregroundStyle(.tertiary) - Text("Select a plugin to view details") - .font(.headline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - // MARK: - Helpers - - private func isPluginInstalled(_ pluginId: String) -> Bool { - pluginManager.plugins.contains { $0.id == pluginId } - || ThemeRegistryInstaller.shared.isInstalled(pluginId) - } - - private func hasUpdate(for plugin: RegistryPlugin) -> Bool { - pluginManager.registryUpdate(for: plugin.id) != nil - } - - private func installPlugin(_ plugin: RegistryPlugin) { - performTrackedOperation(pluginId: plugin.id) { progress in - if plugin.category == .theme { - try await ThemeRegistryInstaller.shared.install(plugin, progress: progress) - } else { - _ = try await pluginManager.installFromRegistry(plugin, progress: progress) - } - } - } - - private func updatePlugin(_ plugin: RegistryPlugin) { - Task { - if plugin.category == .theme { - installTracker.beginInstall(pluginId: plugin.id) - do { - try await ThemeRegistryInstaller.shared.update(plugin) { fraction in - installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) - if fraction >= 1.0 { - installTracker.markInstalling(pluginId: plugin.id) - } - } - installTracker.completeInstall(pluginId: plugin.id) - } catch { - installTracker.failInstall(pluginId: plugin.id, error: error.localizedDescription) - errorTitle = String(localized: "Theme Update Failed") - errorMessage = error.localizedDescription - showErrorAlert = true - } - return - } - - let result = await pluginManager.performRegistryUpdate(plugin) - if case .failed(let error) = result { - errorTitle = String(localized: "Plugin Update Failed") - errorMessage = error.localizedDescription - showErrorAlert = true - } - } - } - - private func performTrackedOperation( - pluginId: String, - operation: @escaping (@escaping @MainActor @Sendable (Double) -> Void) async throws -> Void - ) { - Task { - installTracker.beginInstall(pluginId: pluginId) - do { - try await operation { fraction in - self.installTracker.updateProgress(pluginId: pluginId, fraction: fraction) - if fraction >= 1.0 { - self.installTracker.markInstalling(pluginId: pluginId) - } - } - installTracker.completeInstall(pluginId: pluginId) - } catch { - installTracker.failInstall(pluginId: pluginId, error: error.localizedDescription) - errorTitle = String(localized: "Installation Failed") - errorMessage = error.localizedDescription - showErrorAlert = true - } - } - } - - private func clearSelectionIfNeeded() { - guard let selectedPluginId else { return } - let plugins = registryClient.search(query: searchText, category: selectedCategory) - if !plugins.contains(where: { $0.id == selectedPluginId }) { - self.selectedPluginId = nil - } - } -} diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 60cc3f29ab..390306adee 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -322,12 +322,11 @@ struct InstalledPluginsView: View { PluginIconView(name: plugin.pluginIconName) .font(.title3) .frame(width: 24, height: 24) - .foregroundStyle(plugin.isEnabled ? .secondary : .tertiary) + .foregroundStyle(.secondary) VStack(alignment: .leading, spacing: 2) { Text(plugin.name) .lineLimit(1) - .foregroundStyle(plugin.isEnabled ? .primary : .secondary) HStack(spacing: 4) { Text("v\(plugin.version)") @@ -367,19 +366,8 @@ struct InstalledPluginsView: View { if let selected = selectedPlugin { ScrollView { VStack(alignment: .leading, spacing: 16) { - HStack { - Text(selected.name) - .font(.title3.weight(.semibold)) - Spacer() - Toggle("", isOn: Binding( - get: { selected.isEnabled }, - set: { pluginManager.setEnabled($0, pluginId: selected.id) } - )) - .toggleStyle(.switch) - .labelsHidden() - .controlSize(.small) - .accessibilityLabel(String(format: String(localized: "Enable %@"), selected.name)) - } + Text(selected.name) + .font(.title3.weight(.semibold)) Text("v\(selected.version) · \(selected.source == .builtIn ? String(localized: "Built-in") : String(localized: "User-installed"))") .font(.subheadline) diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift deleted file mode 100644 index 7680465bad..0000000000 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ /dev/null @@ -1,201 +0,0 @@ -// -// RegistryPluginDetailView.swift -// TablePro -// - -import SwiftUI - -struct RegistryPluginDetailView: View { - let plugin: RegistryPlugin - let isInstalled: Bool - var hasUpdate: Bool = false - let installProgress: InstallProgress? - let downloadCount: Int? - let onInstall: () -> Void - var onUpdate: () -> Void = {} - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - Text(plugin.name) - .font(.title3.weight(.semibold)) - - Text(plugin.summary) - .font(.callout) - .foregroundStyle(.secondary) - - Divider() - - Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) { - GridRow { - Text("Category") - .foregroundStyle(.secondary) - .gridColumnAlignment(.leading) - Text(plugin.category.displayName) - .gridColumnAlignment(.leading) - } - - GridRow { - Text("Author") - .foregroundStyle(.secondary) - Text(plugin.author.name) - } - - GridRow { - Text("Version") - .foregroundStyle(.secondary) - Text(plugin.version) - } - - if let minVersion = plugin.minAppVersion { - GridRow { - Text("Requires") - .foregroundStyle(.secondary) - Text("v\(minVersion)+") - } - } - - if let count = downloadCount { - GridRow { - Text("Downloads") - .foregroundStyle(.secondary) - Text(formattedCount(count)) - } - } - - if let homepage = plugin.homepage, let url = URL(string: homepage), - let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { - GridRow { - Text("Homepage") - .foregroundStyle(.secondary) - Link(homepage, destination: url) - .lineLimit(1) - .truncationMode(.middle) - } - } - - if plugin.isVerified { - GridRow { - Text("Status") - .foregroundStyle(.secondary) - Label("Verified", systemImage: "checkmark.seal.fill") - .foregroundStyle(.blue) - } - } - } - .font(.callout) - - if !isInstalled { - Divider() - installActionView - } else if hasUpdate { - Divider() - updateActionView - } else { - Divider() - Label("Installed", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.callout) - } - } - .padding(20) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - @ViewBuilder - private var installActionView: some View { - if let progress = installProgress { - switch progress.phase { - case .downloading(let fraction): - HStack(spacing: 8) { - ProgressView(value: fraction) - Text("\(Int(fraction * 100))%") - .font(.callout) - .foregroundStyle(.secondary) - .monospacedDigit() - } - case .installing: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Installing…") - .font(.callout) - .foregroundStyle(.secondary) - } - case .stagedPendingActivation(let newVersion): - Label( - String(format: String(localized: "v%@ ready to activate"), newVersion), - systemImage: "clock.arrow.circlepath" - ) - .foregroundStyle(.orange) - .font(.callout) - case .completed: - Label("Installed", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.callout) - case .failed: - Button("Retry Install") { onInstall() } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - } - } else { - Button(plugin.category == .theme - ? String(localized: "Install Theme") - : String(localized: "Install Plugin")) { onInstall() } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - .disabled(installProgress != nil) - } - } - - @ViewBuilder - private var updateActionView: some View { - if let progress = installProgress { - switch progress.phase { - case .downloading(let fraction): - HStack(spacing: 8) { - ProgressView(value: fraction) - Text("\(Int(fraction * 100))%") - .font(.callout) - .foregroundStyle(.secondary) - .monospacedDigit() - } - case .installing: - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Updating…") - .font(.callout) - .foregroundStyle(.secondary) - } - case .stagedPendingActivation(let newVersion): - Label( - String(format: String(localized: "v%@ ready to activate"), newVersion), - systemImage: "clock.arrow.circlepath" - ) - .foregroundStyle(.orange) - .font(.callout) - case .completed: - Label("Updated", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.callout) - case .failed: - Button(String(localized: "Retry Update")) { onUpdate() } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - } - } else { - Button(String(format: String(localized: "Update to v%@"), plugin.version)) { onUpdate() } - .buttonStyle(.borderedProminent) - .controlSize(.regular) - } - } - - private func formattedCount(_ count: Int) -> String { - let formatted = count.formatted(.number.grouping(.automatic)) - return count == 1 - ? String(format: String(localized: "%@ download"), formatted) - : String(format: String(localized: "%@ downloads"), formatted) - } -} diff --git a/TablePro/Views/Settings/PluginsSettingsView.swift b/TablePro/Views/Settings/PluginsSettingsView.swift deleted file mode 100644 index 0d1d3a340a..0000000000 --- a/TablePro/Views/Settings/PluginsSettingsView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// PluginsSettingsView.swift -// TablePro -// - -import SwiftUI - -struct PluginsSettingsView: View { - @State private var selectedTab: PluginsSubTab = .installed - @ObservedObject private var navigation = PluginsSettingsNavigation.shared - - var body: some View { - VStack(spacing: 0) { - Picker("Plugins", selection: $selectedTab) { - Text("Installed").tag(PluginsSubTab.installed) - Text("Browse").tag(PluginsSubTab.browse) - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 8) - - Group { - switch selectedTab { - case .installed: - InstalledPluginsView() - case .browse: - BrowsePluginsView() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .onAppear { - guard navigation.pendingRequest != nil else { return } - selectedTab = .installed - } - .onChange(of: navigation.pendingRequest) { request in - guard request != nil else { return } - selectedTab = .installed - } - } -} - -private enum PluginsSubTab: Hashable { - case installed - case browse -} - -#Preview { - PluginsSettingsView() - .frame(width: 550, height: 500) -} diff --git a/TablePro/Views/Settings/SettingsWindowController.swift b/TablePro/Views/Settings/SettingsWindowController.swift index 4bd4748a64..edf8834e0f 100644 --- a/TablePro/Views/Settings/SettingsWindowController.swift +++ b/TablePro/Views/Settings/SettingsWindowController.swift @@ -170,7 +170,8 @@ private struct SettingsPaneContent: View { case .mcp: MCPSettingsView(settings: $settingsManager.mcp) case .plugins: - PluginsSettingsView() + InstalledPluginsView() + .frame(maxWidth: .infinity, maxHeight: .infinity) case .sync: SyncSettingsView() case .account: diff --git a/TableProTests/Core/Database/ConnectionEndReasonTests.swift b/TableProTests/Core/Database/ConnectionEndReasonTests.swift index 78019158e6..f6a52a02f3 100644 --- a/TableProTests/Core/Database/ConnectionEndReasonTests.swift +++ b/TableProTests/Core/Database/ConnectionEndReasonTests.swift @@ -42,15 +42,15 @@ struct ConnectionEndReasonTests { ) } - @Test("A connect that found its plugin switched off records the fix and the window offers it") - func disabledPluginReachesTheWindowAsItsFix() { + @Test("A connect whose plugin failed to load records the fix and the window offers it") + func failedPluginReachesTheWindowAsItsFix() { let id = injectConnectingSession() - let error = PluginError.pluginDisabled(pluginId: "com.TablePro.SQLiteDriver", pluginName: "SQLite") + let error = PluginError.pluginLoadFailed(pluginId: "com.TablePro.SQLiteDriver", pluginName: "SQLite", reason: nil) DatabaseManager.shared.finalizeConnectionFailure(for: id, cancelled: false, error: error) let info = ConnectionFailureClassifier.info(for: error) - let action = ConnectionRecoveryAction.enablePlugin(pluginId: "com.TablePro.SQLiteDriver") + let action = ConnectionRecoveryAction.openPluginSettings(pluginId: "com.TablePro.SQLiteDriver") #expect(DatabaseManager.shared.disconnectReason(for: id) == .connectFailed(info, action)) #expect(phaseAfterSessionEnds(id) == .unavailable(.actionRequired(info, action))) } diff --git a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift index 5b116adf2b..c4e6a0fa11 100644 --- a/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift +++ b/TableProTests/Core/Plugins/DriverUnavailabilityTests.swift @@ -60,7 +60,7 @@ struct DriverUnavailabilityTests { return (manager, root) } - private func makeEntry(id: String, name: String, typeId: String, isEnabled: Bool) -> PluginEntry { + private func makeEntry(id: String, name: String, typeId: String) -> PluginEntry { PluginEntry( id: id, bundle: Bundle.main, @@ -70,7 +70,6 @@ struct DriverUnavailabilityTests { version: "1.0.0", pluginDescription: "", capabilities: [.databaseDriver], - isEnabled: isEnabled, databaseTypeId: typeId, additionalTypeIds: [], pluginIconName: "puzzlepiece", @@ -142,21 +141,12 @@ struct DriverUnavailabilityTests { #expect(manager.driverUnavailability(for: type, registryManifest: nil) == .unknownType) } - @Test("A plugin the user switched off is reported as off, not as missing") - func disabledPlugin() throws { - let (manager, root) = try makeManager() - defer { try? FileManager.default.removeItem(at: root) } - manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server", isEnabled: false)] - - #expect(manager.driverUnavailability(for: .mssql) == .disabled(pluginId: "com.example.mssql", pluginName: "SQL Server Driver")) - } - - @Test("An installed, enabled plugin with no driver failed to load, with the recorded reason") - func enabledPluginThatFailedToLoad() throws { + @Test("An installed plugin with no driver failed to load, with the recorded reason") + func installedPluginThatFailedToLoad() throws { let (manager, root) = try makeManager() defer { try? FileManager.default.removeItem(at: root) } let typeId = "LoadFailDB-\(UUID().uuidString)" - manager.plugins = [makeEntry(id: "com.example.loadfail", name: "Load Fail", typeId: typeId, isEnabled: true)] + manager.plugins = [makeEntry(id: "com.example.loadfail", name: "Load Fail", typeId: typeId)] manager.rejectedPlugins = [makeRejection(typeId: typeId, isOutdated: false, reason: "bad signature")] #expect(manager.driverUnavailability(for: DatabaseType(rawValue: typeId)) == .failedToLoad( @@ -166,6 +156,15 @@ struct DriverUnavailabilityTests { )) } + @Test("Every installed driver plugin offers its database types") + func installedPluginTypesAreAvailable() throws { + let (manager, root) = try makeManager() + defer { try? FileManager.default.removeItem(at: root) } + manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server")] + + #expect(manager.availableDatabaseTypes == [.mssql]) + } + @Test("A rejected plugin that is not listed is still reported with its reason") func rejectedPluginWithoutAnEntry() throws { let (manager, root) = try makeManager() @@ -216,25 +215,11 @@ struct DriverUnavailabilityTests { #expect(pluginName == PluginManager.registryDisplayName(of: .mysql)) } - @Test("Connecting never switches a disabled plugin back on or reinstalls it") - func connectingLeavesADisabledPluginOff() async throws { - let (manager, root) = try makeManager() - defer { try? FileManager.default.removeItem(at: root) } - manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server", isEnabled: false)] - let registry = try makeUnreachableRegistry(root: root) - - try await manager.prepareForConnecting(to: .mssql, registryClient: registry) - - #expect(manager.plugins.first?.isEnabled == false) - #expect(UnreachableRegistryProtocol.requestCount == 0) - #expect(manager.driverUnavailability(for: .mssql) == .disabled(pluginId: "com.example.mssql", pluginName: "SQL Server Driver")) - } - @Test("Connecting never reinstalls a plugin that failed to load") func connectingLeavesAFailedPluginInPlace() async throws { let (manager, root) = try makeManager() defer { try? FileManager.default.removeItem(at: root) } - manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server", isEnabled: true)] + manager.plugins = [makeEntry(id: "com.example.mssql", name: "SQL Server Driver", typeId: "SQL Server")] manager.rejectedPlugins = [makeRejection(typeId: "SQL Server", isOutdated: false, reason: "bad signature")] let registry = try makeUnreachableRegistry(root: root) @@ -245,48 +230,6 @@ struct DriverUnavailabilityTests { #expect(manager.driverUnavailableError(for: .mssql).errorDescription?.contains("SQL Server Driver") == true) } - @Test("A disabled plugin's code is never loaded to answer a driver lookup") - func disabledLazyBundleIsNeverLoaded() throws { - let (manager, root) = try makeManager() - defer { try? FileManager.default.removeItem(at: root) } - let bundleURL = root.appendingPathComponent("Disabled.tableplugin", isDirectory: true) - let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) - try FileManager.default.createDirectory(at: contentsURL, withIntermediateDirectories: true) - let bundleId = "com.TablePro.test.disabled.\(UUID().uuidString)" - let info: [String: Any] = [ - "CFBundleIdentifier": bundleId, - "CFBundleName": "Disabled", - "CFBundleShortVersionString": "1.0.0" - ] - let infoData = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) - try infoData.write(to: contentsURL.appendingPathComponent("Info.plist")) - let bundle = try #require(Bundle(url: bundleURL)) - manager.plugins = [ - PluginEntry( - id: bundleId, - bundle: bundle, - url: bundleURL, - source: .userInstalled, - name: "Disabled", - version: "1.0.0", - pluginDescription: "", - capabilities: [.databaseDriver], - isEnabled: false, - databaseTypeId: "DisabledDB", - additionalTypeIds: [], - pluginIconName: "puzzlepiece", - defaultPort: nil, - exportFormatId: nil, - importFormatId: nil - ) - ] - - manager.activateLazyBundle(at: bundleURL) - - #expect(manager.rejectedPlugins.isEmpty) - #expect(manager.driverPlugins["DisabledDB"] == nil) - } - @Test("A rejected bundle that declares no database types takes them from its registry entry") func rejectionIdentityComesFromTheRegistry() throws { let json = """ diff --git a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift index 6ede3eab05..5b9f166c5c 100644 --- a/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift +++ b/TableProTests/Core/Plugins/PluginLazyActivationVerificationTests.swift @@ -49,7 +49,6 @@ struct PluginLazyActivationVerificationTests { version: "1.0.0", pluginDescription: "", capabilities: [.databaseDriver], - isEnabled: true, databaseTypeId: typeId, additionalTypeIds: [], pluginIconName: "puzzlepiece", @@ -110,7 +109,6 @@ struct PluginLazyActivationVerificationTests { version: "1.0.0", pluginDescription: "", capabilities: [.databaseDriver], - isEnabled: true, databaseTypeId: typeId, additionalTypeIds: [], pluginIconName: "puzzlepiece", diff --git a/TableProTests/Core/Plugins/PluginModelsTests.swift b/TableProTests/Core/Plugins/PluginModelsTests.swift index e9ef68ff7f..7c430f0c24 100644 --- a/TableProTests/Core/Plugins/PluginModelsTests.swift +++ b/TableProTests/Core/Plugins/PluginModelsTests.swift @@ -25,7 +25,6 @@ struct PluginEntryTests { version: "1.0.0", pluginDescription: "A test plugin", capabilities: [.databaseDriver], - isEnabled: true, databaseTypeId: databaseTypeId, additionalTypeIds: additionalTypeIds, pluginIconName: pluginIconName, @@ -90,7 +89,6 @@ struct PluginEntryIdentityTests { version: "0.1.0", pluginDescription: "", capabilities: [], - isEnabled: false, databaseTypeId: nil, additionalTypeIds: [], pluginIconName: "puzzlepiece", diff --git a/TableProTests/Core/Plugins/PluginSettingsTests.swift b/TableProTests/Core/Plugins/PluginSettingsTests.swift index de09adf954..0e5c4e0d58 100644 --- a/TableProTests/Core/Plugins/PluginSettingsTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsTests.swift @@ -259,120 +259,3 @@ struct PluginCapabilityTests { } } } - -@Suite("DisabledPlugins Key Migration", .serialized) -struct DisabledPluginsMigrationTests { - - @Test("migration moves legacy key to namespaced key") - func migrationMovesKey() { - let testKey = "disabledPlugins" - let namespacedKey = "com.TablePro.disabledPlugins" - let defaults = UserDefaults.standard - - // Save current state - let savedNamespaced = defaults.stringArray(forKey: namespacedKey) - let savedLegacy = defaults.stringArray(forKey: testKey) - - defer { - // Restore original state - if let saved = savedNamespaced { - defaults.set(saved, forKey: namespacedKey) - } else { - defaults.removeObject(forKey: namespacedKey) - } - if let saved = savedLegacy { - defaults.set(saved, forKey: testKey) - } else { - defaults.removeObject(forKey: testKey) - } - } - - // Set up legacy key - defaults.removeObject(forKey: namespacedKey) - defaults.set(["plugin.a", "plugin.b"], forKey: testKey) - - // Simulate what migrateDisabledPluginsKey does - if let legacy = defaults.stringArray(forKey: testKey) { - if defaults.stringArray(forKey: namespacedKey) == nil { - defaults.set(legacy, forKey: namespacedKey) - } - defaults.removeObject(forKey: testKey) - } - - #expect(defaults.stringArray(forKey: namespacedKey) == ["plugin.a", "plugin.b"]) - #expect(defaults.stringArray(forKey: testKey) == nil) - } - - @Test("migration is no-op when legacy key absent") - func migrationNoOpWhenAbsent() { - let testKey = "disabledPlugins" - let namespacedKey = "com.TablePro.disabledPlugins" - let defaults = UserDefaults.standard - - let savedNamespaced = defaults.stringArray(forKey: namespacedKey) - let savedLegacy = defaults.stringArray(forKey: testKey) - - defer { - if let saved = savedNamespaced { - defaults.set(saved, forKey: namespacedKey) - } else { - defaults.removeObject(forKey: namespacedKey) - } - if let saved = savedLegacy { - defaults.set(saved, forKey: testKey) - } else { - defaults.removeObject(forKey: testKey) - } - } - - defaults.removeObject(forKey: testKey) - defaults.set(["existing.plugin"], forKey: namespacedKey) - - // Simulate migration - if let legacy = defaults.stringArray(forKey: testKey) { - if defaults.stringArray(forKey: namespacedKey) == nil { - defaults.set(legacy, forKey: namespacedKey) - } - defaults.removeObject(forKey: testKey) - } - - #expect(defaults.stringArray(forKey: namespacedKey) == ["existing.plugin"]) - } - - @Test("migration preserves namespaced key when both keys exist") - func migrationPreservesNamespacedWhenBothExist() { - let testKey = "disabledPlugins" - let namespacedKey = "com.TablePro.disabledPlugins" - let defaults = UserDefaults.standard - - let savedNamespaced = defaults.stringArray(forKey: namespacedKey) - let savedLegacy = defaults.stringArray(forKey: testKey) - - defer { - if let saved = savedNamespaced { - defaults.set(saved, forKey: namespacedKey) - } else { - defaults.removeObject(forKey: namespacedKey) - } - if let saved = savedLegacy { - defaults.set(saved, forKey: testKey) - } else { - defaults.removeObject(forKey: testKey) - } - } - - defaults.set(["legacy.plugin"], forKey: testKey) - defaults.set(["current.plugin"], forKey: namespacedKey) - - // Simulate migration - if let legacy = defaults.stringArray(forKey: testKey) { - if defaults.stringArray(forKey: namespacedKey) == nil { - defaults.set(legacy, forKey: namespacedKey) - } - defaults.removeObject(forKey: testKey) - } - - #expect(defaults.stringArray(forKey: namespacedKey) == ["current.plugin"]) - #expect(defaults.stringArray(forKey: testKey) == nil) - } -} diff --git a/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift b/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift index 16069a6967..a7b0ff9a4e 100644 --- a/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift +++ b/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift @@ -32,7 +32,7 @@ struct PluginSignatureGatePlacementTests { @Test("Every path that loads a plugin's executable goes through the gate first") func loadPathsVerifySignatures() throws { - for function in ["activateLazyBundle", "validateAndLoadBundle", "setEnabled"] { + for function in ["activateLazyBundle", "validateAndLoadBundle"] { let body = try Self.functionBody(named: function) #expect( body.contains("assertLoadable") || body.contains("PluginCodeSignatureVerifier"), @@ -48,7 +48,7 @@ struct PluginSignatureGatePlacementTests { /// `validateDependencies` is allowed because it reads `principalClass` only for a bundle /// that is already `isLoaded`, so the executable it would load is the one a gated path /// already loaded. - let gated = ["setEnabled", "registerBundle", "activateLazyBundle", "validateDependencies"] + let gated = ["registerBundle", "activateLazyBundle", "validateDependencies"] var offenders: [String] = [] for url in try Self.pluginSources() { let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) diff --git a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift index a934d2efee..a5b71c87b6 100644 --- a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift +++ b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift @@ -102,7 +102,6 @@ final class PluginSignatureSweepTests: XCTestCase { version: "1.0", pluginDescription: "", capabilities: [.databaseDriver], - isEnabled: true, databaseTypeId: "ExampleDB", additionalTypeIds: [], pluginIconName: "puzzlepiece", diff --git a/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift b/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift new file mode 100644 index 0000000000..f7091761be --- /dev/null +++ b/TableProTests/Core/Plugins/RegistryCategoryDecodingTests.swift @@ -0,0 +1,25 @@ +// +// RegistryCategoryDecodingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct RegistryCategoryDecodingTests { + @Test("An entry with a category this app does not know decodes as other, and the rest still load") + func unknownCategoryDecodesAsOther() throws { + let json = """ + {"schemaVersion": 2, "plugins": [ + {"id": "com.example.theme", "name": "Theme", "version": "1.0.0", "summary": "test", + "author": {"name": "Tester"}, "category": "theme", "binaries": []}, + {"id": "com.example.driver", "name": "Driver", "version": "1.0.0", "summary": "test", + "author": {"name": "Tester"}, "category": "database-driver", "binaries": []} + ]} + """ + let manifest = try JSONDecoder().decode(RegistryManifest.self, from: Data(json.utf8)) + + #expect(manifest.plugins.map(\.category) == [.other, .databaseDriver]) + } +} diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionWindowPhaseMachineTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionWindowPhaseMachineTests.swift index cbc33a5589..a81e17b919 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionWindowPhaseMachineTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionWindowPhaseMachineTests.swift @@ -46,7 +46,6 @@ struct ConnectionWindowPhaseMachineTests { func actionRequiredKeepsItsAction() { let actions: [ConnectionRecoveryAction] = [ .installPlugin, - .enablePlugin(pluginId: "com.TablePro.SQLiteDriver"), .openPluginSettings(pluginId: nil), .editConnection ] @@ -82,12 +81,11 @@ struct ConnectionWindowPhaseMachineTests { } /// A fix offered for one database type names that type's plugin, so once the type changes it - /// would enable or reveal a plugin the connection no longer uses. + /// would reveal a plugin the connection no longer uses. @Test("A new database type retires every fix offered for the old one") func typeChangeRetiresEveryAction() { let actions: [ConnectionRecoveryAction] = [ .installPlugin, - .enablePlugin(pluginId: "com.TablePro.SQLiteDriver"), .openPluginSettings(pluginId: nil), .editConnection ] @@ -219,10 +217,10 @@ struct ConnectionWindowPhaseMachineTests { /// A connect driven from outside the window, opening a file or a table from a URL, is not the /// window's attempt, so the window learns its result only from the session going away. That /// used to read as "Disconnected" with Reconnect, for a connection that never connected and - /// whose only fix was switching its plugin on. + /// whose only fix was in Settings > Plugins. @Test("A connect the window did not start that failed for its plugin offers the plugin's fix") func unownedFailedConnectKeepsItsFix() { - let action = ConnectionRecoveryAction.enablePlugin(pluginId: "com.TablePro.SQLiteDriver") + let action = ConnectionRecoveryAction.openPluginSettings(pluginId: "com.TablePro.SQLiteDriver") let phase = ConnectionWindowPhaseMachine.onSessionChanged( phase: .connecting, session: ConnectionSessionSnapshot(exists: false, hasDriver: false, endReason: .connectFailed(Self.failure, action)), @@ -304,7 +302,7 @@ struct ConnectionWindowPhaseMachineTests { #expect(ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .unavailable(.disconnected(nil)))) #expect(!ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .unavailable(.cancelled))) - #expect(!ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .unavailable(.actionRequired(Self.failure, .enablePlugin(pluginId: "p"))))) + #expect(!ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .unavailable(.actionRequired(Self.failure, .installPlugin)))) #expect(!ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .connecting)) #expect(!ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .connected)) #expect(!ConnectionWindowPhaseMachine.allowsActivationConnect(phase: .closing)) diff --git a/TableProTests/Core/Services/Infrastructure/LaunchIntentFailureOwnershipTests.swift b/TableProTests/Core/Services/Infrastructure/LaunchIntentFailureOwnershipTests.swift index c44aac239e..240400a114 100644 --- a/TableProTests/Core/Services/Infrastructure/LaunchIntentFailureOwnershipTests.swift +++ b/TableProTests/Core/Services/Infrastructure/LaunchIntentFailureOwnershipTests.swift @@ -13,7 +13,7 @@ import Testing @MainActor struct LaunchIntentFailureOwnershipTests { - private static let underlying = PluginError.pluginDisabled(pluginId: "com.TablePro.SQLiteDriver", pluginName: "SQLite") + private static let underlying = PluginError.pluginLoadFailed(pluginId: "com.TablePro.SQLiteDriver", pluginName: "SQLite", reason: nil) @Test("A file open whose connect failed in its window names that window's connection") func fileOpenFailureNamesItsConnection() { diff --git a/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift b/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift index 04cdfe5285..c4b332ccf1 100644 --- a/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift +++ b/TableProTests/Models/Connection/ConnectionFailureClassifierTests.swift @@ -68,8 +68,6 @@ struct ConnectionFailureClassifierTests { func driverFailuresMapToTheirFix() { let cases: [(PluginError, ConnectionRecoveryAction)] = [ (.pluginNotInstalled("MongoDB"), .installPlugin), - (.pluginDisabled(pluginId: "com.TablePro.SQLiteDriver", pluginName: "SQLite Driver"), - .enablePlugin(pluginId: "com.TablePro.SQLiteDriver")), (.pluginLoadFailed(pluginId: "com.example.driver", pluginName: "Example", reason: "bad signature"), .openPluginSettings(pluginId: "com.example.driver")), (.pluginUpdateUnavailable(reason: "No compatible build"), .openPluginSettings(pluginId: nil)), diff --git a/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift b/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift index 0a997f5ebd..768d85d815 100644 --- a/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift +++ b/TableProTests/Views/Connection/ConnectionRecoveryPresentationTests.swift @@ -12,13 +12,12 @@ import Testing @MainActor struct ConnectionRecoveryPresentationTests { - private static let failure = ConnectionFailureInfo(message: "The plugin is turned off.") + private static let failure = ConnectionFailureInfo(message: "The plugin could not be loaded.") @Test("The pane's button names the fix for each action") func primaryTitleNamesTheFix() { let expected: [(ConnectionRecoveryAction, String)] = [ (.installPlugin, String(localized: "Install Plugin…")), - (.enablePlugin(pluginId: "p"), String(localized: "Enable Plugin")), (.openPluginSettings(pluginId: nil), String(localized: "Open Plugin Settings")), (.editConnection, String(localized: "Edit Connection…")) ] @@ -38,7 +37,6 @@ struct ConnectionRecoveryPresentationTests { @Test("Only a fix made in Settings offers a separate retry") func onlySettingsOffersRetry() { #expect(ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .openPluginSettings(pluginId: nil)))) - #expect(!ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .enablePlugin(pluginId: "p")))) #expect(!ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .installPlugin))) #expect(!ConnectionUnavailableView.offersRetry(for: .actionRequired(Self.failure, .editConnection))) } diff --git a/TableProUITests/PluginDisabledRecoveryUITests.swift b/TableProUITests/PluginDisabledRecoveryUITests.swift deleted file mode 100644 index 212af5fc1a..0000000000 --- a/TableProUITests/PluginDisabledRecoveryUITests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import XCTest - -/// A connection whose plugin is switched off used to fail with a message about a plugin that "may be -/// disabled or missing from the PlugIns directory" and a Try Again button that repeated the same -/// failure. SQLite ships inside the app, so switching its plugin off is the deterministic way to -/// reach that state without the network. -final class PluginDisabledRecoveryUITests: UITestCase { - func testConnectingWithTheDriverSwitchedOffOffersToSwitchItBackOn() throws { - let app = try launchWithSampleDatabase() - - switchOffSQLitePlugin(in: app) - disconnect(in: app) - - let reconnect = app.buttons["Reconnect"] - XCTAssertTrue(reconnect.waitToExist(timeout: 20), "A disconnected window offers Reconnect") - XCTAssertTrue(waitUntilHittable(reconnect, timeout: 10)) - reconnect.click() - - let enable = app.buttons["Enable Plugin"] - XCTAssertTrue( - enable.waitToExist(timeout: 30), - "A connect whose plugin is off must offer to switch it on, not to try the same connect again" - ) - XCTAssertTrue(app.staticTexts["Turn the plugin on to connect."].exists) - XCTAssertTrue(waitUntilHittable(enable, timeout: 10)) - enable.click() - - XCTAssertTrue( - waitForSampleDatabaseWindow(in: app, timeout: 60), - "Enable Plugin must switch the plugin on and reconnect" - ) - XCTAssertFalse(app.buttons["Enable Plugin"].exists) - } - - private func switchOffSQLitePlugin(in app: XCUIApplication) { - let settingsItem = app.menuBars.menuItems["Settings…"] - XCTAssertTrue(settingsItem.waitToExist(timeout: 10)) - settingsItem.click() - - let settings = app.windows["settings"] - XCTAssertTrue(settings.waitToExist(timeout: 10)) - let pluginsPane = settings.toolbars.buttons["Plugins"] - XCTAssertTrue(pluginsPane.waitToExist(timeout: 10)) - pluginsPane.click() - - let filter = settings.searchFields.matching(identifier: "sidebar-filter").firstMatch - XCTAssertTrue(filter.waitToExist(timeout: 10), "The Installed list has a filter field") - XCTAssertTrue(waitUntilHittable(filter, timeout: 10)) - filter.click() - filter.typeText("SQLite") - - let row = settings.outlines.firstMatch.staticTexts["SQLite"].firstMatch - XCTAssertTrue(row.waitToExist(timeout: 15), "The bundled SQLite plugin is listed under Installed") - XCTAssertTrue(waitUntilHittable(row, timeout: 10)) - row.click() - - let toggle = settings.descendants(matching: .any) - .matching(NSPredicate(format: "label == %@", "Enable SQLite")) - .firstMatch - XCTAssertTrue(toggle.waitToExist(timeout: 10)) - XCTAssertTrue(waitUntilHittable(toggle, timeout: 10)) - toggle.click() - - settings.typeKey("w", modifierFlags: .command) - XCTAssertTrue(waitForPredicate(timeout: 10) { !settings.exists }, "Settings should close") - } - - private func disconnect(in app: XCUIApplication) { - let menuBar = app.menuBars.firstMatch - menuBar.menuBarItems["Database"].click() - let disconnectItem = menuBar.menuItems["Disconnect"] - XCTAssertTrue(disconnectItem.waitToExist(timeout: 10)) - disconnectItem.click() - } -}