diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c00c0e1..d7b36a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Plugin system architecture — all 8 database drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) extracted into `.tableplugin` bundles loaded at runtime - Settings > Plugins tab for plugin management — list installed plugins, enable/disable, install from file, uninstall user plugins, view plugin details +- Plugin marketplace — browse, search, and install plugins from the GitHub-hosted registry with SHA-256 checksum verification, ETag caching, and offline fallback - TableProPluginKit framework — shared protocols and types for driver plugins - ClickHouse database support with query progress tracking, EXPLAIN variants, TLS/HTTPS, server-side cancellation, and Parts view diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index cd6214c7..52963931 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -16,6 +16,8 @@ enum PluginError: LocalizedError { case installFailed(String) case pluginConflict(existingName: String) case appVersionTooOld(minimumRequired: String, currentApp: String) + case downloadFailed(String) + case incompatibleWithCurrentApp(minimumRequired: String) var errorDescription: String? { switch self { @@ -39,6 +41,10 @@ enum PluginError: LocalizedError { return String(localized: "A built-in plugin \"\(existingName)\" already provides this bundle ID") case .appVersionTooOld(let minimumRequired, let currentApp): return String(localized: "Plugin requires app version \(minimumRequired) or later, but current version is \(currentApp)") + case .downloadFailed(let reason): + return String(localized: "Plugin download failed: \(reason)") + case .incompatibleWithCurrentApp(let minimumRequired): + return String(localized: "This plugin requires TablePro \(minimumRequired) or later") } } } diff --git a/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift new file mode 100644 index 00000000..d5a762fc --- /dev/null +++ b/TablePro/Core/Plugins/Registry/PluginInstallTracker.swift @@ -0,0 +1,54 @@ +// +// PluginInstallTracker.swift +// TablePro +// + +import Foundation + +@MainActor @Observable +final class PluginInstallTracker { + static let shared = PluginInstallTracker() + + private(set) var activeInstalls: [String: InstallProgress] = [:] + + private init() {} + + func beginInstall(pluginId: String) { + activeInstalls[pluginId] = InstallProgress(phase: .downloading(fraction: 0)) + } + + func updateProgress(pluginId: String, fraction: Double) { + activeInstalls[pluginId]?.phase = .downloading(fraction: min(max(fraction, 0), 1)) + } + + func markInstalling(pluginId: String) { + activeInstalls[pluginId]?.phase = .installing + } + + func completeInstall(pluginId: String) { + activeInstalls[pluginId]?.phase = .completed + } + + func failInstall(pluginId: String, error: String) { + activeInstalls[pluginId]?.phase = .failed(error) + } + + func clearInstall(pluginId: String) { + activeInstalls.removeValue(forKey: pluginId) + } + + func state(for pluginId: String) -> InstallProgress? { + activeInstalls[pluginId] + } +} + +struct InstallProgress: Equatable { + var phase: Phase + + enum Phase: Equatable { + case downloading(fraction: Double) + case installing + case completed + case failed(String) + } +} diff --git a/TablePro/Core/Plugins/Registry/PluginManager+Registry.swift b/TablePro/Core/Plugins/Registry/PluginManager+Registry.swift new file mode 100644 index 00000000..13744f1f --- /dev/null +++ b/TablePro/Core/Plugins/Registry/PluginManager+Registry.swift @@ -0,0 +1,71 @@ +// +// PluginManager+Registry.swift +// TablePro +// + +import CryptoKit +import Foundation + +extension PluginManager { + func installFromRegistry( + _ registryPlugin: RegistryPlugin, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws -> PluginEntry { + if let minAppVersion = registryPlugin.minAppVersion { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending { + throw PluginError.incompatibleWithCurrentApp(minimumRequired: minAppVersion) + } + } + + if let minKit = registryPlugin.minPluginKitVersion, minKit > Self.currentPluginKitVersion { + throw PluginError.incompatibleVersion(required: minKit, current: Self.currentPluginKitVersion) + } + + if plugins.contains(where: { $0.id == registryPlugin.id }) { + throw PluginError.pluginConflict(existingName: registryPlugin.name) + } + + guard let downloadURL = URL(string: registryPlugin.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) + let tempZipURL = tempDir.appendingPathComponent("\(registryPlugin.id).zip") + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + // Use the registry client's configured session for consistent timeouts + let session = await 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)") + } + + await progress(0.5) + + // Verify SHA-256 checksum + let downloadedData = try Data(contentsOf: tempDownloadURL) + let digest = SHA256.hash(data: downloadedData) + let hexChecksum = digest.map { String(format: "%02x", $0) }.joined() + + if hexChecksum != registryPlugin.sha256.lowercased() { + throw PluginError.checksumMismatch + } + + await progress(1.0) + + // Move to our temp directory for installPlugin + try FileManager.default.moveItem(at: tempDownloadURL, to: tempZipURL) + + return try await installPlugin(from: tempZipURL) + } +} diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift new file mode 100644 index 00000000..af7d0cb1 --- /dev/null +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -0,0 +1,132 @@ +// +// RegistryClient.swift +// TablePro +// + +import Foundation +import os + +@MainActor @Observable +final class RegistryClient { + static let shared = RegistryClient() + + private(set) var manifest: RegistryManifest? + private(set) var fetchState: RegistryFetchState = .idle + private(set) var lastFetchDate: Date? + + private var cachedETag: String? { + get { UserDefaults.standard.string(forKey: "registryETag") } + set { UserDefaults.standard.set(newValue, forKey: "registryETag") } + } + + let session: URLSession + private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient") + + // swiftlint:disable:next force_unwrapping + private static let registryURL = URL(string: + "https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")! + + private static let manifestCacheKey = "registryManifestCache" + private static let lastFetchKey = "registryLastFetch" + + private init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 15 + config.timeoutIntervalForResource = 30 + config.waitsForConnectivity = true + self.session = URLSession(configuration: config) + + if let cachedData = UserDefaults.standard.data(forKey: Self.manifestCacheKey), + let cached = try? JSONDecoder().decode(RegistryManifest.self, from: cachedData) { + manifest = cached + lastFetchDate = UserDefaults.standard.object(forKey: Self.lastFetchKey) as? Date + } + } + + // MARK: - Fetching + + func fetchManifest(forceRefresh: Bool = false) async { + fetchState = .loading + + var request = URLRequest(url: Self.registryURL) + if !forceRefresh, let etag = cachedETag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + switch httpResponse.statusCode { + case 304: + Self.logger.debug("Registry manifest not modified (304)") + fetchState = .loaded + + case 200...299: + let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data) + manifest = decoded + + UserDefaults.standard.set(data, forKey: Self.manifestCacheKey) + cachedETag = httpResponse.value(forHTTPHeaderField: "ETag") + lastFetchDate = Date() + UserDefaults.standard.set(lastFetchDate, forKey: Self.lastFetchKey) + + fetchState = .loaded + Self.logger.info("Fetched registry manifest with \(decoded.plugins.count) plugin(s)") + + default: + let message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) + Self.logger.error("Registry fetch failed: HTTP \(httpResponse.statusCode) \(message)") + fallbackToCacheOrFail(message: "Server returned HTTP \(httpResponse.statusCode)") + } + } catch is DecodingError { + Self.logger.error("Failed to decode registry manifest") + fallbackToCacheOrFail(message: String(localized: "Failed to parse plugin registry")) + } catch { + Self.logger.error("Registry fetch failed: \(error.localizedDescription)") + fallbackToCacheOrFail(message: error.localizedDescription) + } + } + + private func fallbackToCacheOrFail(message: String) { + if manifest != nil { + fetchState = .loaded + Self.logger.warning("Using cached registry manifest after fetch failure") + } else { + fetchState = .failed(message) + } + } + + // MARK: - Search + + func search(query: String, category: RegistryCategory?) -> [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 RegistryFetchState: Equatable, Sendable { + case idle + case loading + case loaded + case failed(String) +} diff --git a/TablePro/Core/Plugins/Registry/RegistryModels.swift b/TablePro/Core/Plugins/Registry/RegistryModels.swift new file mode 100644 index 00000000..912bdb96 --- /dev/null +++ b/TablePro/Core/Plugins/Registry/RegistryModels.swift @@ -0,0 +1,52 @@ +// +// RegistryModels.swift +// TablePro +// + +import Foundation + +struct RegistryManifest: Codable, Sendable { + let schemaVersion: Int + let plugins: [RegistryPlugin] +} + +struct RegistryPlugin: Codable, Sendable, Identifiable { + let id: String + let name: String + let version: String + let summary: String + let author: RegistryAuthor + let homepage: String? + let category: RegistryCategory + let downloadURL: String + let sha256: String + let minAppVersion: String? + let minPluginKitVersion: Int? + let iconName: String? + let isVerified: Bool +} + +struct RegistryAuthor: Codable, Sendable { + let name: String + let url: String? +} + +enum RegistryCategory: String, Codable, Sendable, CaseIterable, Identifiable { + 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") + } + } +} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 351ac371..7569f2fb 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -2874,6 +2874,9 @@ }, "Database Driver" : { + }, + "Database Drivers" : { + }, "Database File" : { "localizations" : { @@ -4011,6 +4014,9 @@ }, "Export Format" : { + }, + "Export Formats" : { + }, "Export multiple tables" : { "localizations" : { @@ -4165,6 +4171,9 @@ } } } + }, + "Failed to load plugin registry" : { + }, "Failed to load preview using encoding: %@. Try selecting a different text encoding from the encoding picker and reload the preview." : { "localizations" : { @@ -4228,6 +4237,9 @@ } } } + }, + "Failed to parse plugin registry" : { + }, "Failed to parse statement at line %lld: %@" : { "localizations" : { @@ -4737,6 +4749,9 @@ } } } + }, + "Homepage" : { + }, "Host" : { "localizations" : { @@ -4838,6 +4853,9 @@ }, "Import Format" : { + }, + "Import Formats" : { + }, "Import from DDL" : { "extractionState" : "stale", @@ -5031,9 +5049,21 @@ } } } + }, + "Install" : { + }, "Install from File..." : { + }, + "Install Plugin" : { + + }, + "Installation Failed" : { + + }, + "Installed" : { + }, "Installed Plugins" : { @@ -5450,6 +5480,9 @@ } } } + }, + "Loading plugins..." : { + }, "Loading schemas..." : { "localizations" : { @@ -6187,6 +6220,9 @@ } } } + }, + "No plugins found" : { + }, "No primary key selected (not recommended)" : { "extractionState" : "stale", @@ -6750,6 +6786,9 @@ } } } + }, + "Other" : { + }, "Page size must be between %@ and %@" : { "localizations" : { @@ -6899,6 +6938,9 @@ }, "Plugin does not contain a compatible binary for this architecture" : { + }, + "Plugin download failed: %@" : { + }, "Plugin Installation Failed" : { @@ -8088,6 +8130,9 @@ } } } + }, + "Search plugins..." : { + }, "Search queries..." : { "localizations" : { @@ -9180,6 +9225,9 @@ } } } + }, + "Themes" : { + }, "This database has no tables yet." : { "extractionState" : "stale", @@ -9231,6 +9279,9 @@ } } } + }, + "This plugin requires TablePro %@ or later" : { + }, "This query may permanently modify or delete data." : { "localizations" : { @@ -9584,6 +9635,9 @@ } } } + }, + "Try Again" : { + }, "Type" : { "localizations" : { @@ -9855,6 +9909,9 @@ } } } + }, + "Verified by TablePro" : { + }, "Verify certificate and hostname" : { "localizations" : { diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift new file mode 100644 index 00000000..99669f11 --- /dev/null +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -0,0 +1,216 @@ +// +// BrowsePluginsView.swift +// TablePro +// + +import SwiftUI + +struct BrowsePluginsView: View { + private let registryClient = RegistryClient.shared + private let pluginManager = PluginManager.shared + private let installTracker = PluginInstallTracker.shared + + @State private var searchText = "" + @State private var selectedCategory: RegistryCategory? + @State private var selectedPluginId: String? + @State private var showErrorAlert = false + @State private var errorMessage = "" + + var body: some View { + VStack(spacing: 0) { + searchAndFilterBar + .padding(.horizontal, 16) + .padding(.top, 8) + + Divider() + .padding(.top, 8) + + contentView + } + .task { + if registryClient.fetchState == .idle { + await registryClient.fetchManifest() + } + } + .alert("Installation Failed", isPresented: $showErrorAlert) { + Button("OK") {} + } message: { + Text(errorMessage) + } + } + + // MARK: - Search & Filter + + @ViewBuilder + private var searchAndFilterBar: some View { + VStack(spacing: 8) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search plugins...", text: $searchText) + .textFieldStyle(.plain) + + if !searchText.isEmpty { + Button { + searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(8) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + FilterChip(title: "All", isSelected: selectedCategory == nil) { + selectedCategory = nil + } + + ForEach(RegistryCategory.allCases) { category in + FilterChip( + title: category.displayName, + isSelected: selectedCategory == category + ) { + selectedCategory = category + } + } + } + } + } + } + + // MARK: - Content + + @ViewBuilder + private var contentView: some View { + switch registryClient.fetchState { + case .idle, .loading: + VStack { + Spacer() + ProgressView("Loading plugins...") + Spacer() + } + .frame(maxWidth: .infinity) + + case .loaded: + let plugins = registryClient.search(query: searchText, category: selectedCategory) + if plugins.isEmpty { + VStack(spacing: 8) { + Spacer() + Image(systemName: "puzzlepiece.extension") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("No plugins found") + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(plugins) { plugin in + VStack(spacing: 0) { + RegistryPluginRow( + plugin: plugin, + isInstalled: isPluginInstalled(plugin.id), + installProgress: installTracker.state(for: plugin.id), + onInstall: { installPlugin(plugin) }, + onToggleDetail: { + withAnimation(.easeInOut(duration: 0.2)) { + selectedPluginId = selectedPluginId == plugin.id ? nil : plugin.id + } + } + ) + + if selectedPluginId == plugin.id { + RegistryPluginDetailView( + plugin: plugin, + isInstalled: isPluginInstalled(plugin.id), + installProgress: installTracker.state(for: plugin.id), + onInstall: { installPlugin(plugin) } + ) + } + + Divider() + } + } + } + .padding(.horizontal, 16) + } + } + + case .failed(let message): + VStack(spacing: 12) { + Spacer() + Image(systemName: "wifi.slash") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Failed to load plugin registry") + .font(.headline) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + Button("Try Again") { + Task { + await registryClient.fetchManifest(forceRefresh: true) + } + } + .buttonStyle(.bordered) + Spacer() + } + .frame(maxWidth: .infinity) + } + } + + // MARK: - Helpers + + private func isPluginInstalled(_ pluginId: String) -> Bool { + pluginManager.plugins.contains { $0.id == pluginId } + } + + private func installPlugin(_ plugin: RegistryPlugin) { + Task { + installTracker.beginInstall(pluginId: plugin.id) + do { + _ = try await pluginManager.installFromRegistry(plugin) { fraction in + installTracker.updateProgress(pluginId: plugin.id, fraction: fraction) + } + installTracker.completeInstall(pluginId: plugin.id) + } catch { + installTracker.failInstall(pluginId: plugin.id, error: error.localizedDescription) + errorMessage = error.localizedDescription + showErrorAlert = true + } + } + } +} + +// MARK: - Filter Chip + +private struct FilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + isSelected ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .foregroundStyle(isSelected ? .primary : .secondary) + } +} diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift new file mode 100644 index 00000000..ce198277 --- /dev/null +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -0,0 +1,222 @@ +// +// InstalledPluginsView.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProPluginKit +import UniformTypeIdentifiers + +struct InstalledPluginsView: View { + private let pluginManager = PluginManager.shared + + @State private var selectedPluginId: String? + @State private var isInstalling = false + @State private var showErrorAlert = false + @State private var errorAlertTitle = "" + @State private var errorAlertMessage = "" + + var body: some View { + Form { + Section("Installed Plugins") { + ForEach(pluginManager.plugins) { plugin in + pluginRow(plugin) + } + } + + Section { + HStack { + Button("Install from File...") { + installFromFile() + } + .disabled(isInstalling) + + if isInstalling { + ProgressView() + .controlSize(.small) + } + } + } + + if let selected = selectedPlugin { + pluginDetailSection(selected) + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .alert(errorAlertTitle, isPresented: $showErrorAlert) { + Button("OK") {} + } message: { + Text(errorAlertMessage) + } + } + + // MARK: - Plugin Row + + @ViewBuilder + private func pluginRow(_ plugin: PluginEntry) -> some View { + HStack { + Image(systemName: plugin.iconName) + .frame(width: 20) + .foregroundStyle(plugin.isEnabled ? .primary : .tertiary) + + VStack(alignment: .leading, spacing: 2) { + Text(plugin.name) + .foregroundStyle(plugin.isEnabled ? .primary : .secondary) + + HStack(spacing: 4) { + Text("v\(plugin.version)") + .font(.caption) + .foregroundStyle(.secondary) + + Text(plugin.source == .builtIn ? "Built-in" : "User") + .font(.caption) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + plugin.source == .builtIn + ? Color.blue.opacity(0.15) + : Color.green.opacity(0.15), + in: RoundedRectangle(cornerRadius: 3) + ) + .foregroundStyle(plugin.source == .builtIn ? .blue : .green) + } + } + + Spacer() + + Toggle("", isOn: Binding( + get: { plugin.isEnabled }, + set: { pluginManager.setEnabled($0, pluginId: plugin.id) } + )) + .toggleStyle(.switch) + .labelsHidden() + } + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.2)) { + selectedPluginId = selectedPluginId == plugin.id ? nil : plugin.id + } + } + } + + // MARK: - Detail Section + + private var selectedPlugin: PluginEntry? { + guard let id = selectedPluginId else { return nil } + return pluginManager.plugins.first { $0.id == id } + } + + @ViewBuilder + private func pluginDetailSection(_ plugin: PluginEntry) -> some View { + Section(plugin.name) { + LabeledContent("Version:", value: plugin.version) + LabeledContent("Bundle ID:", value: plugin.id) + LabeledContent("Source:", value: plugin.source == .builtIn + ? String(localized: "Built-in") + : String(localized: "User-installed")) + + if !plugin.capabilities.isEmpty { + LabeledContent("Capabilities:") { + Text(plugin.capabilities.map(\.displayName).joined(separator: ", ")) + } + } + + if let typeId = plugin.databaseTypeId { + LabeledContent("Database Type:", value: typeId) + + if !plugin.additionalTypeIds.isEmpty { + LabeledContent("Also handles:", value: plugin.additionalTypeIds.joined(separator: ", ")) + } + + if let port = plugin.defaultPort { + LabeledContent("Default Port:", value: "\(port)") + } + } + + if !plugin.pluginDescription.isEmpty { + Text(plugin.pluginDescription) + .font(.callout) + .foregroundStyle(.secondary) + } + + if plugin.source == .userInstalled { + HStack { + Spacer() + Button("Uninstall", role: .destructive) { + uninstallPlugin(plugin) + } + } + } + } + } + + // MARK: - Actions + + private func installFromFile() { + let panel = NSOpenPanel() + panel.title = String(localized: "Select Plugin Archive") + panel.allowedContentTypes = [.zip] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + + guard panel.runModal() == .OK, let url = panel.url else { return } + + isInstalling = true + Task { + defer { isInstalling = false } + do { + let entry = try await pluginManager.installPlugin(from: url) + selectedPluginId = entry.id + } catch { + errorAlertTitle = String(localized: "Plugin Installation Failed") + errorAlertMessage = error.localizedDescription + showErrorAlert = true + } + } + } + + private func uninstallPlugin(_ plugin: PluginEntry) { + Task { @MainActor in + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Uninstall Plugin?"), + message: String(localized: "\"\(plugin.name)\" will be removed from your system. This action cannot be undone."), + confirmButton: String(localized: "Uninstall"), + cancelButton: String(localized: "Cancel") + ) + + guard confirmed else { return } + + do { + try pluginManager.uninstallPlugin(id: plugin.id) + selectedPluginId = nil + } catch { + errorAlertTitle = String(localized: "Uninstall Failed") + errorAlertMessage = error.localizedDescription + showErrorAlert = true + } + } + } +} + +// MARK: - PluginCapability Display Names + +private extension PluginCapability { + var displayName: String { + switch self { + case .databaseDriver: String(localized: "Database Driver") + case .exportFormat: String(localized: "Export Format") + case .importFormat: String(localized: "Import Format") + case .sqlDialect: String(localized: "SQL Dialect") + case .aiProvider: String(localized: "AI Provider") + case .cellRenderer: String(localized: "Cell Renderer") + case .sidebarPanel: String(localized: "Sidebar Panel") + } + } +} + +#Preview { + InstalledPluginsView() + .frame(width: 550, height: 500) +} diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift new file mode 100644 index 00000000..76969397 --- /dev/null +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -0,0 +1,77 @@ +// +// RegistryPluginDetailView.swift +// TablePro +// + +import SwiftUI + +struct RegistryPluginDetailView: View { + let plugin: RegistryPlugin + let isInstalled: Bool + let installProgress: InstallProgress? + let onInstall: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(plugin.summary) + .font(.callout) + .foregroundStyle(.secondary) + + HStack(spacing: 16) { + detailItem(label: "Category", value: plugin.category.displayName) + + if let minVersion = plugin.minAppVersion { + detailItem(label: "Requires", value: "v\(minVersion)+") + } + } + + HStack(spacing: 16) { + detailItem(label: "Author", value: plugin.author.name) + + if let homepage = plugin.homepage, let url = URL(string: homepage) { + Link(destination: url) { + HStack(spacing: 2) { + Text("Homepage") + .font(.caption) + Image(systemName: "arrow.up.right.square") + .font(.caption2) + } + } + } + } + + if plugin.isVerified { + HStack(spacing: 4) { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.blue) + .font(.caption) + Text("Verified by TablePro") + .font(.caption) + .foregroundStyle(.blue) + } + } + + if !isInstalled, installProgress == nil { + Button("Install Plugin") { + onInstall() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding(.leading, 34) + .padding(.vertical, 8) + } + + @ViewBuilder + private func detailItem(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption2) + .foregroundStyle(.tertiary) + .textCase(.uppercase) + Text(value) + .font(.caption) + } + } +} diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift new file mode 100644 index 00000000..94b5048e --- /dev/null +++ b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift @@ -0,0 +1,98 @@ +// +// RegistryPluginRow.swift +// TablePro +// + +import SwiftUI + +struct RegistryPluginRow: View { + let plugin: RegistryPlugin + let isInstalled: Bool + let installProgress: InstallProgress? + let onInstall: () -> Void + let onToggleDetail: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: plugin.iconName ?? "puzzlepiece") + .frame(width: 24, height: 24) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(plugin.name) + .fontWeight(.medium) + + if plugin.isVerified { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.blue) + .font(.caption) + } + } + + HStack(spacing: 6) { + Text("v\(plugin.version)") + .font(.caption) + .foregroundStyle(.secondary) + + Text("\u{2022}") + .font(.caption2) + .foregroundStyle(.quaternary) + + Text(plugin.author.name) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + actionButton + } + .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { + onToggleDetail() + } + } + + @ViewBuilder + private var actionButton: some View { + if isInstalled { + Text("Installed") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 6)) + } else if let progress = installProgress { + switch progress.phase { + case .downloading(let fraction): + ProgressView(value: fraction) + .frame(width: 60) + .progressViewStyle(.linear) + + case .installing: + ProgressView() + .controlSize(.small) + + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + + case .failed: + Button("Retry") { + onInstall() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } else { + Button("Install") { + onInstall() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } +} diff --git a/TablePro/Views/Settings/PluginsSettingsView.swift b/TablePro/Views/Settings/PluginsSettingsView.swift index c51abce8..61188460 100644 --- a/TablePro/Views/Settings/PluginsSettingsView.swift +++ b/TablePro/Views/Settings/PluginsSettingsView.swift @@ -2,220 +2,36 @@ // PluginsSettingsView.swift // TablePro // -// Plugin management tab in Settings: list, enable/disable, install, uninstall -// -import AppKit import SwiftUI -import TableProPluginKit -import UniformTypeIdentifiers struct PluginsSettingsView: View { - private let pluginManager = PluginManager.shared - - @State private var selectedPluginId: String? - @State private var isInstalling = false - @State private var showErrorAlert = false - @State private var errorAlertTitle = "" - @State private var errorAlertMessage = "" + @State private var selectedTab: PluginsSubTab = .installed var body: some View { - Form { - Section("Installed Plugins") { - ForEach(pluginManager.plugins) { plugin in - pluginRow(plugin) - } - } - - Section { - HStack { - Button("Install from File...") { - installFromFile() - } - .disabled(isInstalling) - - if isInstalling { - ProgressView() - .controlSize(.small) - } - } - } - - if let selected = selectedPlugin { - pluginDetailSection(selected) + VStack(spacing: 0) { + Picker("", selection: $selectedTab) { + Text("Installed").tag(PluginsSubTab.installed) + Text("Browse").tag(PluginsSubTab.browse) } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - .alert(errorAlertTitle, isPresented: $showErrorAlert) { - Button("OK") {} - } message: { - Text(errorAlertMessage) - } - } - - // MARK: - Plugin Row - - @ViewBuilder - private func pluginRow(_ plugin: PluginEntry) -> some View { - HStack { - Image(systemName: plugin.iconName) - .frame(width: 20) - .foregroundStyle(plugin.isEnabled ? .primary : .tertiary) - - VStack(alignment: .leading, spacing: 2) { - Text(plugin.name) - .foregroundStyle(plugin.isEnabled ? .primary : .secondary) - - HStack(spacing: 4) { - Text("v\(plugin.version)") - .font(.caption) - .foregroundStyle(.secondary) - - Text(plugin.source == .builtIn ? "Built-in" : "User") - .font(.caption) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background( - plugin.source == .builtIn - ? Color.blue.opacity(0.15) - : Color.green.opacity(0.15), - in: RoundedRectangle(cornerRadius: 3) - ) - .foregroundStyle(plugin.source == .builtIn ? .blue : .green) - } - } - - Spacer() - - Toggle("", isOn: Binding( - get: { plugin.isEnabled }, - set: { pluginManager.setEnabled($0, pluginId: plugin.id) } - )) - .toggleStyle(.switch) + .pickerStyle(.segmented) .labelsHidden() - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - selectedPluginId = selectedPluginId == plugin.id ? nil : plugin.id - } - } - } - - // MARK: - Detail Section - - private var selectedPlugin: PluginEntry? { - guard let id = selectedPluginId else { return nil } - return pluginManager.plugins.first { $0.id == id } - } + .padding(.horizontal, 16) + .padding(.top, 12) - @ViewBuilder - private func pluginDetailSection(_ plugin: PluginEntry) -> some View { - Section(plugin.name) { - LabeledContent("Version:", value: plugin.version) - LabeledContent("Bundle ID:", value: plugin.id) - LabeledContent("Source:", value: plugin.source == .builtIn - ? String(localized: "Built-in") - : String(localized: "User-installed")) - - if !plugin.capabilities.isEmpty { - LabeledContent("Capabilities:") { - Text(plugin.capabilities.map(\.displayName).joined(separator: ", ")) - } - } - - if let typeId = plugin.databaseTypeId { - LabeledContent("Database Type:", value: typeId) - - if !plugin.additionalTypeIds.isEmpty { - LabeledContent("Also handles:", value: plugin.additionalTypeIds.joined(separator: ", ")) - } - - if let port = plugin.defaultPort { - LabeledContent("Default Port:", value: "\(port)") - } - } - - if !plugin.pluginDescription.isEmpty { - Text(plugin.pluginDescription) - .font(.callout) - .foregroundStyle(.secondary) - } - - if plugin.source == .userInstalled { - HStack { - Spacer() - Button("Uninstall", role: .destructive) { - uninstallPlugin(plugin) - } - } - } - } - } - - // MARK: - Actions - - private func installFromFile() { - let panel = NSOpenPanel() - panel.title = String(localized: "Select Plugin Archive") - panel.allowedContentTypes = [.zip] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - - guard panel.runModal() == .OK, let url = panel.url else { return } - - isInstalling = true - Task { - defer { isInstalling = false } - do { - let entry = try await pluginManager.installPlugin(from: url) - selectedPluginId = entry.id - } catch { - errorAlertTitle = String(localized: "Plugin Installation Failed") - errorAlertMessage = error.localizedDescription - showErrorAlert = true - } - } - } - - private func uninstallPlugin(_ plugin: PluginEntry) { - Task { @MainActor in - let confirmed = await AlertHelper.confirmDestructive( - title: String(localized: "Uninstall Plugin?"), - message: String(localized: "\"\(plugin.name)\" will be removed from your system. This action cannot be undone."), - confirmButton: String(localized: "Uninstall"), - cancelButton: String(localized: "Cancel") - ) - - guard confirmed else { return } - - do { - try pluginManager.uninstallPlugin(id: plugin.id) - selectedPluginId = nil - } catch { - errorAlertTitle = String(localized: "Uninstall Failed") - errorAlertMessage = error.localizedDescription - showErrorAlert = true + switch selectedTab { + case .installed: + InstalledPluginsView() + case .browse: + BrowsePluginsView() } } } } -// MARK: - PluginCapability Display Names - -private extension PluginCapability { - var displayName: String { - switch self { - case .databaseDriver: String(localized: "Database Driver") - case .exportFormat: String(localized: "Export Format") - case .importFormat: String(localized: "Import Format") - case .sqlDialect: String(localized: "SQL Dialect") - case .aiProvider: String(localized: "AI Provider") - case .cellRenderer: String(localized: "Cell Renderer") - case .sidebarPanel: String(localized: "Sidebar Panel") - } - } +private enum PluginsSubTab: Hashable { + case installed + case browse } #Preview {