Skip to content

Commit 08228d6

Browse files
authored
Merge pull request #219 from datlechin/fix/plugin-system-tech-debt
fix: resolve plugin system tech debt across 6 areas
2 parents 1866a1b + 3bc7a99 commit 08228d6

6 files changed

Lines changed: 118 additions & 7 deletions

File tree

CHANGELOG.md

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

1010
### Added
1111

12+
- Plugin capability enforcement — registration now gated on declared capabilities, with validation warnings for mismatches
13+
- Plugin dependency declarations — plugins can declare required dependencies via `TableProPlugin.dependencies`, validated at load time
14+
- Plugin state change notification (`pluginStateDidChange`) posted when plugins are enabled/disabled
15+
- Restart recommendation banner in Settings > Plugins after uninstalling a plugin
1216
- Startup commands — run custom SQL after connecting (e.g., SET time_zone) in Connection > Advanced tab
1317
- Plugin system architecture — all 8 database drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) extracted into `.tableplugin` bundles loaded at runtime
1418
- Export format plugins — all 5 export formats (CSV, JSON, SQL, XLSX, MQL) extracted into `.tableplugin` bundles with plugin-provided option views and per-table option columns

Plugins/TableProPluginKit/PluginDatabaseDriver.swift

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,46 @@ public extension PluginDatabaseDriver {
136136
}
137137

138138
func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult {
139-
var sql = query
140-
for param in parameters.reversed() {
141-
if let range = sql.range(of: "?", options: .backwards) {
142-
let replacement = param.map { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } ?? "NULL"
143-
sql.replaceSubrange(range, with: replacement)
139+
var sql = ""
140+
var paramIndex = 0
141+
var inSingleQuote = false
142+
var inDoubleQuote = false
143+
var isEscaped = false
144+
145+
for char in query {
146+
if isEscaped {
147+
isEscaped = false
148+
sql.append(char)
149+
continue
150+
}
151+
152+
if char == "\\" && (inSingleQuote || inDoubleQuote) {
153+
isEscaped = true
154+
sql.append(char)
155+
continue
156+
}
157+
158+
if char == "'" && !inDoubleQuote {
159+
inSingleQuote.toggle()
160+
} else if char == "\"" && !inSingleQuote {
161+
inDoubleQuote.toggle()
162+
}
163+
164+
if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count {
165+
if let value = parameters[paramIndex] {
166+
let escaped = value
167+
.replacingOccurrences(of: "\\", with: "\\\\")
168+
.replacingOccurrences(of: "'", with: "''")
169+
sql.append("'\(escaped)'")
170+
} else {
171+
sql.append("NULL")
172+
}
173+
paramIndex += 1
174+
} else {
175+
sql.append(char)
144176
}
145177
}
178+
146179
return try await execute(query: sql)
147180
}
148181

Plugins/TableProPluginKit/TableProPlugin.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ public protocol TableProPlugin: AnyObject {
55
static var pluginVersion: String { get }
66
static var pluginDescription: String { get }
77
static var capabilities: [PluginCapability] { get }
8+
static var dependencies: [String] { get }
89

910
init()
1011
}
12+
13+
public extension TableProPlugin {
14+
static var dependencies: [String] { [] }
15+
}

TablePro/Core/Plugins/PluginManager.swift

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ final class PluginManager {
1515

1616
private(set) var plugins: [PluginEntry] = []
1717

18+
private(set) var needsRestart = false
19+
1820
private(set) var driverPlugins: [String: any DriverPlugin] = [:]
1921

2022
private(set) var exportPlugins: [String: any ExportFormatPlugin] = [:]
@@ -53,6 +55,8 @@ final class PluginManager {
5355

5456
loadPlugins(from: userPluginsDir, source: .userInstalled)
5557

58+
validateDependencies()
59+
5660
Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s)")
5761
}
5862

@@ -126,6 +130,7 @@ final class PluginManager {
126130
)
127131

128132
plugins.append(entry)
133+
validateCapabilityDeclarations(principalClass, pluginId: bundleId)
129134

130135
if entry.isEnabled {
131136
let instance = principalClass.init()
@@ -140,7 +145,12 @@ final class PluginManager {
140145
// MARK: - Capability Registration
141146

142147
private func registerCapabilities(_ instance: any TableProPlugin, pluginId: String) {
148+
let declared = Set(type(of: instance).capabilities)
149+
143150
if let driver = instance as? any DriverPlugin {
151+
if !declared.contains(.databaseDriver) {
152+
Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway")
153+
}
144154
let typeId = type(of: driver).databaseTypeId
145155
driverPlugins[typeId] = driver
146156
for additionalId in type(of: driver).additionalDatabaseTypeIds {
@@ -150,12 +160,28 @@ final class PluginManager {
150160
}
151161

152162
if let exportPlugin = instance as? any ExportFormatPlugin {
163+
if !declared.contains(.exportFormat) {
164+
Self.logger.warning("Plugin '\(pluginId)' conforms to ExportFormatPlugin but does not declare .exportFormat capability — registering anyway")
165+
}
153166
let formatId = type(of: exportPlugin).formatId
154167
exportPlugins[formatId] = exportPlugin
155168
Self.logger.debug("Registered export plugin '\(pluginId)' for format '\(formatId)'")
156169
}
157170
}
158171

172+
private func validateCapabilityDeclarations(_ pluginType: any TableProPlugin.Type, pluginId: String) {
173+
let declared = Set(pluginType.capabilities)
174+
let isDriver = pluginType is any DriverPlugin.Type
175+
let isExporter = pluginType is any ExportFormatPlugin.Type
176+
177+
if declared.contains(.databaseDriver) && !isDriver {
178+
Self.logger.warning("Plugin '\(pluginId)' declares .databaseDriver but does not conform to DriverPlugin")
179+
}
180+
if declared.contains(.exportFormat) && !isExporter {
181+
Self.logger.warning("Plugin '\(pluginId)' declares .exportFormat but does not conform to ExportFormatPlugin")
182+
}
183+
}
184+
159185
private func unregisterCapabilities(pluginId: String) {
160186
driverPlugins = driverPlugins.filter { _, value in
161187
guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true }
@@ -200,6 +226,7 @@ final class PluginManager {
200226
}
201227

202228
Self.logger.info("Plugin '\(pluginId)' \(enabled ? "enabled" : "disabled")")
229+
NotificationCenter.default.post(name: .pluginStateDidChange, object: nil, userInfo: ["pluginId": pluginId])
203230
}
204231

205232
// MARK: - Install / Uninstall
@@ -292,12 +319,29 @@ final class PluginManager {
292319
disabledPluginIds = disabled
293320

294321
Self.logger.info("Uninstalled plugin '\(id)'")
322+
needsRestart = true
323+
}
324+
325+
// MARK: - Dependency Validation
326+
327+
private func validateDependencies() {
328+
let loadedIds = Set(plugins.map(\.id))
329+
for plugin in plugins where plugin.isEnabled {
330+
guard let principalClass = plugin.bundle.principalClass as? any TableProPlugin.Type else { continue }
331+
let deps = principalClass.dependencies
332+
for dep in deps {
333+
if !loadedIds.contains(dep) {
334+
Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is not installed")
335+
} else if let depEntry = plugins.first(where: { $0.id == dep }), !depEntry.isEnabled {
336+
Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is disabled")
337+
}
338+
}
339+
}
295340
}
296341

297342
// MARK: - Code Signature Verification
298343

299-
// TODO: Replace with actual team identifier
300-
private static let signingTeamId = "YOURTEAMID"
344+
private static let signingTeamId = "D7HJ5TFYCU"
301345

302346
private func createSigningRequirement() -> SecRequirement? {
303347
var requirement: SecRequirement?

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,8 @@ extension Notification.Name {
3434

3535
static let sshTunnelDied = Notification.Name("sshTunnelDied")
3636
static let lastWindowDidClose = Notification.Name("lastWindowDidClose")
37+
38+
// MARK: - Plugins
39+
40+
static let pluginStateDidChange = Notification.Name("pluginStateDidChange")
3741
}

TablePro/Views/Settings/Plugins/InstalledPluginsView.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,30 @@ struct InstalledPluginsView: View {
1616
@State private var showErrorAlert = false
1717
@State private var errorAlertTitle = ""
1818
@State private var errorAlertMessage = ""
19+
@State private var dismissedRestartBanner = false
1920

2021
var body: some View {
2122
Form {
23+
if pluginManager.needsRestart && !dismissedRestartBanner {
24+
Section {
25+
HStack(spacing: 8) {
26+
Image(systemName: "arrow.clockwise.circle.fill")
27+
.foregroundStyle(.orange)
28+
Text("Restart TablePro to fully unload removed plugins.")
29+
.font(.callout)
30+
.foregroundStyle(.secondary)
31+
Spacer()
32+
Button {
33+
dismissedRestartBanner = true
34+
} label: {
35+
Image(systemName: "xmark")
36+
.foregroundStyle(.secondary)
37+
}
38+
.buttonStyle(.plain)
39+
}
40+
}
41+
}
42+
2243
Section("Installed Plugins") {
2344
ForEach(pluginManager.plugins) { plugin in
2445
pluginRow(plugin)

0 commit comments

Comments
 (0)