Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -437,6 +447,26 @@ 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")

// 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 {
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
Expand Down
44 changes: 42 additions & 2 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,47 @@ 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)
}

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)

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)

Expand All @@ -243,7 +283,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<Void, Error>) in
process.terminationHandler = { proc in
Expand Down
35 changes: 35 additions & 0 deletions TablePro/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@
<string>com.tablepro.sql</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>TablePro Plugin</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.tablepro.plugin</string>
</array>
<key>LSTypeIsPackage</key>
<true/>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
Expand All @@ -51,6 +65,27 @@
</dict>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.tablepro.plugin</string>
<key>UTTypeDescription</key>
<string>TablePro Plugin</string>
<key>UTTypeConformsTo</key>
<array>
<string>com.apple.bundle</string>
<string>com.apple.package</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>tableplugin</string>
</array>
</dict>
</dict>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
Expand Down
8 changes: 7 additions & 1 deletion TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -6111,6 +6111,9 @@
}
}
}
},
"No export formats available. Enable export plugins in Settings > Plugins." : {

},
"No Foreign Keys Yet" : {
"localizations" : {
Expand Down Expand Up @@ -7787,6 +7790,9 @@
}
}
}
},
"Restart TablePro to fully unload removed plugins." : {

},
"Retention" : {
"localizations" : {
Expand Down Expand Up @@ -8294,7 +8300,7 @@
}
}
},
"Select Plugin Archive" : {
"Select Plugin" : {

},
"Select SQL File..." : {
Expand Down
25 changes: 23 additions & 2 deletions TablePro/Views/Settings/Plugins/InstalledPluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,22 @@ struct InstalledPluginsView: View {
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
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 }
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: {
Expand Down Expand Up @@ -177,13 +193,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 }
Expand Down