From d3282138a6f7e055d61b6afd5a5b086b0e339c1d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 13:30:38 +0700 Subject: [PATCH 1/2] feat: add direct .tableplugin bundle installation --- CHANGELOG.md | 1 + TablePro/AppDelegate.swift | 28 ++++++++++++++ TablePro/Core/Plugins/PluginManager.swift | 38 ++++++++++++++++++- TablePro/Info.plist | 35 +++++++++++++++++ TablePro/Resources/Localizable.xcstrings | 8 +++- .../Plugins/InstalledPluginsView.swift | 22 ++++++++++- 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d854de1..4f95703b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Direct `.tableplugin` bundle installation via file picker, Finder double-click, and drag-and-drop - Plugin capability enforcement — registration now gated on declared capabilities, with validation warnings for mismatches - Plugin dependency declarations — plugins can declare required dependencies via `TableProPlugin.dependencies`, validated at load time - Plugin state change notification (`pluginStateDidChange`) posted when plugins are enabled/disabled diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 1ada4589..d740db88 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -160,6 +160,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + // Handle .tableplugin files (double-click from Finder) + let pluginURLs = urls.filter { $0.pathExtension == "tableplugin" } + if !pluginURLs.isEmpty { + Task { @MainActor in + for url in pluginURLs { + await self.handlePluginInstall(url) + } + } + } + // Handle database connection URLs (e.g. postgresql://user@host/db) let databaseURLs = urls.filter { url in guard let scheme = url.scheme?.lowercased() else { return false } @@ -437,6 +447,24 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + @MainActor + private func handlePluginInstall(_ url: URL) async { + do { + let entry = try await PluginManager.shared.installPlugin(from: url) + Self.logger.info("Installed plugin '\(entry.name)' from Finder") + + UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab") + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } catch { + Self.logger.error("Plugin install failed: \(error.localizedDescription)") + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + private func scheduleQueuedDatabaseURLProcessing() { Task { @MainActor [weak self] in var ready = false diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 3d3c9ce8..57820a7f 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -231,7 +231,41 @@ final class PluginManager { // MARK: - Install / Uninstall - func installPlugin(from zipURL: URL) async throws -> PluginEntry { + func installPlugin(from url: URL) async throws -> PluginEntry { + if url.pathExtension == "tableplugin" { + return try await installBundle(from: url) + } else { + return try await installFromZip(from: url) + } + } + + private func installBundle(from url: URL) async throws -> PluginEntry { + guard let sourceBundle = Bundle(url: url) else { + throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") + } + + try verifyCodeSignature(bundle: sourceBundle) + + let newBundleId = sourceBundle.bundleIdentifier ?? url.lastPathComponent + if let existing = plugins.first(where: { $0.id == newBundleId }), existing.source == .builtIn { + throw PluginError.pluginConflict(existingName: existing.name) + } + + let fm = FileManager.default + let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent) + + if fm.fileExists(atPath: destURL.path) { + try fm.removeItem(at: destURL) + } + try fm.copyItem(at: url, to: destURL) + + let entry = try loadPlugin(at: destURL, source: .userInstalled) + + Self.logger.info("Installed plugin '\(entry.name)' v\(entry.version)") + return entry + } + + private func installFromZip(from url: URL) async throws -> PluginEntry { let fm = FileManager.default let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -243,7 +277,7 @@ final class PluginManager { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - process.arguments = ["-xk", zipURL.path, tempDir.path] + process.arguments = ["-xk", url.path, tempDir.path] try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in process.terminationHandler = { proc in diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 0c177b19..c9553039 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -28,6 +28,20 @@ com.tablepro.sql + + CFBundleTypeName + TablePro Plugin + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + com.tablepro.plugin + + LSTypeIsPackage + + UTImportedTypeDeclarations @@ -51,6 +65,27 @@ + UTExportedTypeDeclarations + + + UTTypeIdentifier + com.tablepro.plugin + UTTypeDescription + TablePro Plugin + UTTypeConformsTo + + com.apple.bundle + com.apple.package + + UTTypeTagSpecification + + public.filename-extension + + tableplugin + + + + CFBundleURLTypes diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index e08a9d81..bfcd5a15 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6111,6 +6111,9 @@ } } } + }, + "No export formats available. Enable export plugins in Settings > Plugins." : { + }, "No Foreign Keys Yet" : { "localizations" : { @@ -7787,6 +7790,9 @@ } } } + }, + "Restart TablePro to fully unload removed plugins." : { + }, "Retention" : { "localizations" : { @@ -8294,7 +8300,7 @@ } } }, - "Select Plugin Archive" : { + "Select Plugin" : { }, "Select SQL File..." : { diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 2b039f98..589845cf 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -66,6 +66,19 @@ struct InstalledPluginsView: View { } .formStyle(.grouped) .scrollContentBackground(.hidden) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + guard let provider = providers.first else { return false } + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in + guard let data = data as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) else { return } + let ext = url.pathExtension.lowercased() + guard ext == "zip" || ext == "tableplugin" else { return } + Task { @MainActor in + installPlugin(from: url) + } + } + return true + } .alert(errorAlertTitle, isPresented: $showErrorAlert) { Button("OK") {} } message: { @@ -177,13 +190,18 @@ struct InstalledPluginsView: View { private func installFromFile() { let panel = NSOpenPanel() - panel.title = String(localized: "Select Plugin Archive") - panel.allowedContentTypes = [.zip] + panel.title = String(localized: "Select Plugin") + panel.allowedContentTypes = [.zip] + (UTType("com.tablepro.plugin").map { [$0] } ?? []) panel.allowsMultipleSelection = false panel.canChooseDirectories = false + panel.treatsFilePackagesAsDirectories = false guard panel.runModal() == .OK, let url = panel.url else { return } + installPlugin(from: url) + } + + private func installPlugin(from url: URL) { isInstalling = true Task { defer { isInstalling = false } From 6a42a08fb9d5cc600cb200afff6689230318ecc8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 13:33:50 +0700 Subject: [PATCH 2/2] fix: handle duplicate plugin entries on reinstall and improve drop validation --- TablePro/AppDelegate.swift | 2 ++ TablePro/Core/Plugins/PluginManager.swift | 6 ++++++ TablePro/Views/Settings/Plugins/InstalledPluginsView.swift | 5 ++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index d740db88..11655f0d 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -453,6 +453,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { let entry = try await PluginManager.shared.installPlugin(from: url) Self.logger.info("Installed plugin '\(entry.name)' from Finder") + // Navigate to Settings > Plugins tab. + // showSettingsWindow: is a private AppKit selector — no public API alternative exists. UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab") NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } catch { diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 57820a7f..96b98f5e 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -251,6 +251,12 @@ final class PluginManager { throw PluginError.pluginConflict(existingName: existing.name) } + if let existingIndex = plugins.firstIndex(where: { $0.id == newBundleId }) { + unregisterCapabilities(pluginId: newBundleId) + plugins[existingIndex].bundle.unload() + plugins.remove(at: existingIndex) + } + let fm = FileManager.default let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent) diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 589845cf..57260e59 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -67,7 +67,10 @@ struct InstalledPluginsView: View { .formStyle(.grouped) .scrollContentBackground(.hidden) .onDrop(of: [.fileURL], isTargeted: nil) { providers in - guard let provider = providers.first else { return false } + guard let provider = providers.first, + provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else { + return false + } provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in guard let data = data as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) else { return }