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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- 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
- Restart recommendation banner in Settings > Plugins after uninstalling a plugin
- Startup commands — run custom SQL after connecting (e.g., SET time_zone) in Connection > Advanced tab
- Plugin system architecture — all 8 database drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) extracted into `.tableplugin` bundles loaded at runtime
- 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
Expand Down
43 changes: 38 additions & 5 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,46 @@ public extension PluginDatabaseDriver {
}

func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult {
var sql = query
for param in parameters.reversed() {
if let range = sql.range(of: "?", options: .backwards) {
let replacement = param.map { "'\($0.replacingOccurrences(of: "'", with: "''"))'" } ?? "NULL"
sql.replaceSubrange(range, with: replacement)
var sql = ""
var paramIndex = 0
var inSingleQuote = false
var inDoubleQuote = false
var isEscaped = false

for char in query {
if isEscaped {
isEscaped = false
sql.append(char)
continue
}

if char == "\\" && (inSingleQuote || inDoubleQuote) {
isEscaped = true
sql.append(char)
continue
}

if char == "'" && !inDoubleQuote {
inSingleQuote.toggle()
} else if char == "\"" && !inSingleQuote {
inDoubleQuote.toggle()
}

if char == "?" && !inSingleQuote && !inDoubleQuote && paramIndex < parameters.count {
if let value = parameters[paramIndex] {
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "'", with: "''")
sql.append("'\(escaped)'")
} else {
sql.append("NULL")
}
paramIndex += 1
} else {
sql.append(char)
}
}

return try await execute(query: sql)
}

Expand Down
5 changes: 5 additions & 0 deletions Plugins/TableProPluginKit/TableProPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ public protocol TableProPlugin: AnyObject {
static var pluginVersion: String { get }
static var pluginDescription: String { get }
static var capabilities: [PluginCapability] { get }
static var dependencies: [String] { get }

init()
}

public extension TableProPlugin {
static var dependencies: [String] { [] }
}
48 changes: 46 additions & 2 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ final class PluginManager {

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

private(set) var needsRestart = false

private(set) var driverPlugins: [String: any DriverPlugin] = [:]

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

loadPlugins(from: userPluginsDir, source: .userInstalled)

validateDependencies()

Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s)")
}

Expand Down Expand Up @@ -126,6 +130,7 @@ final class PluginManager {
)

plugins.append(entry)
validateCapabilityDeclarations(principalClass, pluginId: bundleId)

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

private func registerCapabilities(_ instance: any TableProPlugin, pluginId: String) {
let declared = Set(type(of: instance).capabilities)

if let driver = instance as? any DriverPlugin {
if !declared.contains(.databaseDriver) {
Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway")
}
let typeId = type(of: driver).databaseTypeId
driverPlugins[typeId] = driver
for additionalId in type(of: driver).additionalDatabaseTypeIds {
Expand All @@ -150,12 +160,28 @@ final class PluginManager {
}

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

private func validateCapabilityDeclarations(_ pluginType: any TableProPlugin.Type, pluginId: String) {
let declared = Set(pluginType.capabilities)
let isDriver = pluginType is any DriverPlugin.Type
let isExporter = pluginType is any ExportFormatPlugin.Type

if declared.contains(.databaseDriver) && !isDriver {
Self.logger.warning("Plugin '\(pluginId)' declares .databaseDriver but does not conform to DriverPlugin")
}
if declared.contains(.exportFormat) && !isExporter {
Self.logger.warning("Plugin '\(pluginId)' declares .exportFormat but does not conform to ExportFormatPlugin")
}
}

private func unregisterCapabilities(pluginId: String) {
driverPlugins = driverPlugins.filter { _, value in
guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true }
Expand Down Expand Up @@ -200,6 +226,7 @@ final class PluginManager {
}

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

// MARK: - Install / Uninstall
Expand Down Expand Up @@ -292,12 +319,29 @@ final class PluginManager {
disabledPluginIds = disabled

Self.logger.info("Uninstalled plugin '\(id)'")
needsRestart = true
}

// MARK: - Dependency Validation

private func validateDependencies() {
let loadedIds = Set(plugins.map(\.id))
for plugin in plugins where plugin.isEnabled {
guard let principalClass = plugin.bundle.principalClass as? any TableProPlugin.Type else { continue }
let deps = principalClass.dependencies
for dep in deps {
if !loadedIds.contains(dep) {
Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is not installed")
} else if let depEntry = plugins.first(where: { $0.id == dep }), !depEntry.isEnabled {
Self.logger.warning("Plugin '\(plugin.id)' requires '\(dep)' which is disabled")
}
}
}
}

// MARK: - Code Signature Verification

// TODO: Replace with actual team identifier
private static let signingTeamId = "YOURTEAMID"
private static let signingTeamId = "D7HJ5TFYCU"

private func createSigningRequirement() -> SecRequirement? {
var requirement: SecRequirement?
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Services/Infrastructure/AppNotifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ extension Notification.Name {

static let sshTunnelDied = Notification.Name("sshTunnelDied")
static let lastWindowDidClose = Notification.Name("lastWindowDidClose")

// MARK: - Plugins

static let pluginStateDidChange = Notification.Name("pluginStateDidChange")
}
21 changes: 21 additions & 0 deletions TablePro/Views/Settings/Plugins/InstalledPluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,30 @@ struct InstalledPluginsView: View {
@State private var showErrorAlert = false
@State private var errorAlertTitle = ""
@State private var errorAlertMessage = ""
@State private var dismissedRestartBanner = false

var body: some View {
Form {
if pluginManager.needsRestart && !dismissedRestartBanner {
Section {
HStack(spacing: 8) {
Image(systemName: "arrow.clockwise.circle.fill")
.foregroundStyle(.orange)
Text("Restart TablePro to fully unload removed plugins.")
.font(.callout)
.foregroundStyle(.secondary)
Spacer()
Button {
dismissedRestartBanner = true
} label: {
Image(systemName: "xmark")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}

Section("Installed Plugins") {
ForEach(pluginManager.plugins) { plugin in
pluginRow(plugin)
Expand Down