Skip to content

Commit 58deb81

Browse files
authored
Merge pull request #221 from datlechin/feat/direct-tableplugin-install
feat: add direct .tableplugin bundle installation
2 parents eec73fe + 6a42a08 commit 58deb81

6 files changed

Lines changed: 138 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Direct `.tableplugin` bundle installation via file picker, Finder double-click, and drag-and-drop
1213
- Plugin capability enforcement — registration now gated on declared capabilities, with validation warnings for mismatches
1314
- Plugin dependency declarations — plugins can declare required dependencies via `TableProPlugin.dependencies`, validated at load time
1415
- Plugin state change notification (`pluginStateDidChange`) posted when plugins are enabled/disabled

TablePro/AppDelegate.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
160160
}
161161
}
162162

163+
// Handle .tableplugin files (double-click from Finder)
164+
let pluginURLs = urls.filter { $0.pathExtension == "tableplugin" }
165+
if !pluginURLs.isEmpty {
166+
Task { @MainActor in
167+
for url in pluginURLs {
168+
await self.handlePluginInstall(url)
169+
}
170+
}
171+
}
172+
163173
// Handle database connection URLs (e.g. postgresql://user@host/db)
164174
let databaseURLs = urls.filter { url in
165175
guard let scheme = url.scheme?.lowercased() else { return false }
@@ -437,6 +447,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
437447
}
438448
}
439449

450+
@MainActor
451+
private func handlePluginInstall(_ url: URL) async {
452+
do {
453+
let entry = try await PluginManager.shared.installPlugin(from: url)
454+
Self.logger.info("Installed plugin '\(entry.name)' from Finder")
455+
456+
// Navigate to Settings > Plugins tab.
457+
// showSettingsWindow: is a private AppKit selector — no public API alternative exists.
458+
UserDefaults.standard.set(SettingsTab.plugins.rawValue, forKey: "selectedSettingsTab")
459+
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
460+
} catch {
461+
Self.logger.error("Plugin install failed: \(error.localizedDescription)")
462+
AlertHelper.showErrorSheet(
463+
title: String(localized: "Plugin Installation Failed"),
464+
message: error.localizedDescription,
465+
window: NSApp.keyWindow
466+
)
467+
}
468+
}
469+
440470
private func scheduleQueuedDatabaseURLProcessing() {
441471
Task { @MainActor [weak self] in
442472
var ready = false

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,47 @@ final class PluginManager {
231231

232232
// MARK: - Install / Uninstall
233233

234-
func installPlugin(from zipURL: URL) async throws -> PluginEntry {
234+
func installPlugin(from url: URL) async throws -> PluginEntry {
235+
if url.pathExtension == "tableplugin" {
236+
return try await installBundle(from: url)
237+
} else {
238+
return try await installFromZip(from: url)
239+
}
240+
}
241+
242+
private func installBundle(from url: URL) async throws -> PluginEntry {
243+
guard let sourceBundle = Bundle(url: url) else {
244+
throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)")
245+
}
246+
247+
try verifyCodeSignature(bundle: sourceBundle)
248+
249+
let newBundleId = sourceBundle.bundleIdentifier ?? url.lastPathComponent
250+
if let existing = plugins.first(where: { $0.id == newBundleId }), existing.source == .builtIn {
251+
throw PluginError.pluginConflict(existingName: existing.name)
252+
}
253+
254+
if let existingIndex = plugins.firstIndex(where: { $0.id == newBundleId }) {
255+
unregisterCapabilities(pluginId: newBundleId)
256+
plugins[existingIndex].bundle.unload()
257+
plugins.remove(at: existingIndex)
258+
}
259+
260+
let fm = FileManager.default
261+
let destURL = userPluginsDir.appendingPathComponent(url.lastPathComponent)
262+
263+
if fm.fileExists(atPath: destURL.path) {
264+
try fm.removeItem(at: destURL)
265+
}
266+
try fm.copyItem(at: url, to: destURL)
267+
268+
let entry = try loadPlugin(at: destURL, source: .userInstalled)
269+
270+
Self.logger.info("Installed plugin '\(entry.name)' v\(entry.version)")
271+
return entry
272+
}
273+
274+
private func installFromZip(from url: URL) async throws -> PluginEntry {
235275
let fm = FileManager.default
236276
let tempDir = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
237277

@@ -243,7 +283,7 @@ final class PluginManager {
243283

244284
let process = Process()
245285
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
246-
process.arguments = ["-xk", zipURL.path, tempDir.path]
286+
process.arguments = ["-xk", url.path, tempDir.path]
247287

248288
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
249289
process.terminationHandler = { proc in

TablePro/Info.plist

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@
2828
<string>com.tablepro.sql</string>
2929
</array>
3030
</dict>
31+
<dict>
32+
<key>CFBundleTypeName</key>
33+
<string>TablePro Plugin</string>
34+
<key>CFBundleTypeRole</key>
35+
<string>Viewer</string>
36+
<key>LSHandlerRank</key>
37+
<string>Owner</string>
38+
<key>LSItemContentTypes</key>
39+
<array>
40+
<string>com.tablepro.plugin</string>
41+
</array>
42+
<key>LSTypeIsPackage</key>
43+
<true/>
44+
</dict>
3145
</array>
3246
<key>UTImportedTypeDeclarations</key>
3347
<array>
@@ -51,6 +65,27 @@
5165
</dict>
5266
</dict>
5367
</array>
68+
<key>UTExportedTypeDeclarations</key>
69+
<array>
70+
<dict>
71+
<key>UTTypeIdentifier</key>
72+
<string>com.tablepro.plugin</string>
73+
<key>UTTypeDescription</key>
74+
<string>TablePro Plugin</string>
75+
<key>UTTypeConformsTo</key>
76+
<array>
77+
<string>com.apple.bundle</string>
78+
<string>com.apple.package</string>
79+
</array>
80+
<key>UTTypeTagSpecification</key>
81+
<dict>
82+
<key>public.filename-extension</key>
83+
<array>
84+
<string>tableplugin</string>
85+
</array>
86+
</dict>
87+
</dict>
88+
</array>
5489
<key>CFBundleURLTypes</key>
5590
<array>
5691
<dict>

TablePro/Resources/Localizable.xcstrings

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6111,6 +6111,9 @@
61116111
}
61126112
}
61136113
}
6114+
},
6115+
"No export formats available. Enable export plugins in Settings > Plugins." : {
6116+
61146117
},
61156118
"No Foreign Keys Yet" : {
61166119
"localizations" : {
@@ -7787,6 +7790,9 @@
77877790
}
77887791
}
77897792
}
7793+
},
7794+
"Restart TablePro to fully unload removed plugins." : {
7795+
77907796
},
77917797
"Retention" : {
77927798
"localizations" : {
@@ -8294,7 +8300,7 @@
82948300
}
82958301
}
82968302
},
8297-
"Select Plugin Archive" : {
8303+
"Select Plugin" : {
82988304

82998305
},
83008306
"Select SQL File..." : {

TablePro/Views/Settings/Plugins/InstalledPluginsView.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,22 @@ struct InstalledPluginsView: View {
6666
}
6767
.formStyle(.grouped)
6868
.scrollContentBackground(.hidden)
69+
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
70+
guard let provider = providers.first,
71+
provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else {
72+
return false
73+
}
74+
provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier) { data, _ in
75+
guard let data = data as? Data,
76+
let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
77+
let ext = url.pathExtension.lowercased()
78+
guard ext == "zip" || ext == "tableplugin" else { return }
79+
Task { @MainActor in
80+
installPlugin(from: url)
81+
}
82+
}
83+
return true
84+
}
6985
.alert(errorAlertTitle, isPresented: $showErrorAlert) {
7086
Button("OK") {}
7187
} message: {
@@ -177,13 +193,18 @@ struct InstalledPluginsView: View {
177193

178194
private func installFromFile() {
179195
let panel = NSOpenPanel()
180-
panel.title = String(localized: "Select Plugin Archive")
181-
panel.allowedContentTypes = [.zip]
196+
panel.title = String(localized: "Select Plugin")
197+
panel.allowedContentTypes = [.zip] + (UTType("com.tablepro.plugin").map { [$0] } ?? [])
182198
panel.allowsMultipleSelection = false
183199
panel.canChooseDirectories = false
200+
panel.treatsFilePackagesAsDirectories = false
184201

185202
guard panel.runModal() == .OK, let url = panel.url else { return }
186203

204+
installPlugin(from: url)
205+
}
206+
207+
private func installPlugin(from url: URL) {
187208
isInstalling = true
188209
Task {
189210
defer { isInstalling = false }

0 commit comments

Comments
 (0)