Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- Replaced ~40 hardcoded `DatabaseType` switches across ~20 UI files with dynamic plugin property lookups via `PluginManager`, so third-party plugins get correct UI behavior (colors, labels, editor language, feature toggles) automatically
- ConnectionFormView now fully dynamic: pgpass toggle, password visibility, and SSH/SSL tab visibility all driven by plugin metadata (`FieldSection`, `hidesPassword`, `supportsSSH`/`supportsSSL`) instead of hardcoded type checks
Comment thread
datlechin marked this conversation as resolved.
- Replaced `AppState.isMongoDB`/`isRedis` booleans with `AppState.editorLanguage: EditorLanguage` for extensible editor language detection
- Theme colors now derived from plugin `brandColorHex` instead of hardcoded `Theme.mysqlColor` etc.
- Sidebar labels ("Tables"/"Collections"/"Keys"), toolbar preview labels, and AI prompt language detection now use plugin metadata
Expand Down
11 changes: 10 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,16 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let databaseDisplayName = "PostgreSQL"
static let iconName = "cylinder.fill"
static let defaultPort = 5432
static let additionalConnectionFields: [ConnectionField] = []
static let additionalConnectionFields: [ConnectionField] = [
ConnectionField(
id: "usePgpass",
label: String(localized: "Use ~/.pgpass"),
defaultValue: "false",
fieldType: .toggle,
section: .authentication,
hidesPassword: true
)
]
static let additionalDatabaseTypeIds: [String] = ["Redshift"]

// MARK: - UI/Capability Metadata
Expand Down
2 changes: 2 additions & 0 deletions Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin {
// MARK: - UI/Capability Metadata

static let requiresAuthentication = false
static let supportsSSH = false
static let supportsSSL = false
static let connectionMode: ConnectionMode = .fileBased
static let urlSchemes: [String] = ["sqlite"]
static let fileExtensions: [String] = ["db", "sqlite", "sqlite3"]
Expand Down
29 changes: 28 additions & 1 deletion Plugins/TableProPluginKit/ConnectionField.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import Foundation

public enum FieldSection: String, Codable, Sendable {
case authentication
case advanced
}

public struct ConnectionField: Codable, Sendable {
public struct IntRange: Codable, Sendable, Equatable {
public let lowerBound: Int
Expand Down Expand Up @@ -70,6 +75,8 @@ public struct ConnectionField: Codable, Sendable {
public let isRequired: Bool
public let defaultValue: String?
public let fieldType: FieldType
public let section: FieldSection
public let hidesPassword: Bool

/// Backward-compatible convenience: true when fieldType is .secure
public var isSecure: Bool {
Expand All @@ -84,13 +91,33 @@ public struct ConnectionField: Codable, Sendable {
required: Bool = false,
secure: Bool = false,
defaultValue: String? = nil,
fieldType: FieldType? = nil
fieldType: FieldType? = nil,
section: FieldSection = .advanced,
hidesPassword: Bool = false
) {
self.id = id
self.label = label
self.placeholder = placeholder
self.isRequired = required
self.defaultValue = defaultValue
self.fieldType = fieldType ?? (secure ? .secure : .text)
self.section = section
self.hidesPassword = hidesPassword
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
label = try container.decode(String.self, forKey: .label)
placeholder = try container.decodeIfPresent(String.self, forKey: .placeholder) ?? ""
isRequired = try container.decodeIfPresent(Bool.self, forKey: .isRequired) ?? false
defaultValue = try container.decodeIfPresent(String.self, forKey: .defaultValue)
fieldType = try container.decode(FieldType.self, forKey: .fieldType)
section = try container.decodeIfPresent(FieldSection.self, forKey: .section) ?? .advanced
hidesPassword = try container.decodeIfPresent(Bool.self, forKey: .hidesPassword) ?? false
}

private enum CodingKeys: String, CodingKey {
case id, label, placeholder, isRequired, defaultValue, fieldType, section, hidesPassword
}
}
4 changes: 4 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public protocol DriverPlugin: TableProPlugin {
static var structureColumnFields: [StructureColumnField] { get }
static var defaultPrimaryKeyColumn: String? { get }
static var supportsQueryProgress: Bool { get }
static var supportsSSH: Bool { get }
static var supportsSSL: Bool { get }
Comment thread
datlechin marked this conversation as resolved.
}

public extension DriverPlugin {
Expand Down Expand Up @@ -98,4 +100,6 @@ public extension DriverPlugin {
}
static var defaultPrimaryKeyColumn: String? { nil }
static var supportsQueryProgress: Bool { false }
static var supportsSSH: Bool { true }
static var supportsSSL: Bool { true }
}
4 changes: 1 addition & 3 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,7 @@ enum DatabaseDriverFactory {
}

private static func resolvePassword(for connection: DatabaseConnection) -> String {
if connection.usePgpass
&& (connection.type == .postgresql || connection.type == .redshift)
{
if connection.usePgpass {
return ""
}
Comment thread
datlechin marked this conversation as resolved.
return ConnectionStorage.shared.loadPassword(for: connection.id) ?? ""
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum PluginError: LocalizedError {
case downloadFailed(String)
case pluginNotInstalled(String)
case incompatibleWithCurrentApp(minimumRequired: String)
case invalidDescriptor(pluginId: String, reason: String)

var errorDescription: String? {
switch self {
Expand Down Expand Up @@ -48,6 +49,8 @@ enum PluginError: LocalizedError {
return String(localized: "The \(databaseType) plugin is not installed. You can download it from the plugin marketplace.")
case .incompatibleWithCurrentApp(let minimumRequired):
return String(localized: "This plugin requires TablePro \(minimumRequired) or later")
case .invalidDescriptor(let pluginId, let reason):
return String(localized: "Plugin '\(pluginId)' has an invalid descriptor: \(reason)")
}
}
}
83 changes: 83 additions & 0 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ final class PluginManager {
if !declared.contains(.databaseDriver) {
Self.logger.warning("Plugin '\(pluginId)' conforms to DriverPlugin but does not declare .databaseDriver capability — registering anyway")
}
do {
try validateDriverDescriptor(type(of: driver), pluginId: pluginId)
} catch {
Self.logger.error("Plugin '\(pluginId)' rejected: \(error.localizedDescription)")
return
}
Comment thread
datlechin marked this conversation as resolved.
let typeId = type(of: driver).databaseTypeId
driverPlugins[typeId] = driver
for additionalId in type(of: driver).additionalDatabaseTypeIds {
Expand Down Expand Up @@ -293,6 +299,73 @@ final class PluginManager {
}
}

// MARK: - Descriptor Validation

/// Reject-level validation: runs synchronously before registration.
/// Checks only properties already accessed during the loading flow.
private func validateDriverDescriptor(_ driverType: any DriverPlugin.Type, pluginId: String) throws {
guard !driverType.databaseTypeId.trimmingCharacters(in: .whitespaces).isEmpty else {
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseTypeId is empty")
}

guard !driverType.databaseDisplayName.trimmingCharacters(in: .whitespaces).isEmpty else {
throw PluginError.invalidDescriptor(pluginId: pluginId, reason: "databaseDisplayName is empty")
}

let typeId = driverType.databaseTypeId
if let existingPlugin = driverPlugins[typeId] {
let existingName = Swift.type(of: existingPlugin).databaseDisplayName
throw PluginError.invalidDescriptor(
pluginId: pluginId,
reason: "databaseTypeId '\(typeId)' is already registered by '\(existingName)'"
)
}

let allAdditionalIds = driverType.additionalDatabaseTypeIds
if allAdditionalIds.contains(typeId) {
Self.logger.warning("Plugin '\(pluginId)': additionalDatabaseTypeIds contains the primary databaseTypeId '\(typeId)'")
}

for additionalId in allAdditionalIds {
if let existingPlugin = driverPlugins[additionalId] {
let existingName = Swift.type(of: existingPlugin).databaseDisplayName
throw PluginError.invalidDescriptor(
pluginId: pluginId,
reason: "additionalDatabaseTypeId '\(additionalId)' is already registered by '\(existingName)'"
)
}
}
}

/// Warn-level connection field validation. Called lazily when fields are accessed,
/// not during plugin loading (protocol witness tables may be unstable for dynamically loaded bundles).
private func validateConnectionFields(_ fields: [ConnectionField], pluginId: String) {
var seenIds = Set<String>()
for field in fields {
if field.id.trimmingCharacters(in: .whitespaces).isEmpty {
Self.logger.warning("Plugin '\(pluginId)': connection field has empty id")
}
if field.label.trimmingCharacters(in: .whitespaces).isEmpty {
Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' has empty label")
}
if !seenIds.insert(field.id).inserted {
Self.logger.warning("Plugin '\(pluginId)': duplicate connection field id '\(field.id)'")
}
if case .dropdown(let options) = field.fieldType, options.isEmpty {
Self.logger.warning("Plugin '\(pluginId)': connection field '\(field.id)' is a dropdown with no options")
}
}
}

private func validateDialectDescriptor(_ dialect: SQLDialectDescriptor, pluginId: String) {
if dialect.identifierQuote.trimmingCharacters(in: .whitespaces).isEmpty {
Self.logger.warning("Plugin '\(pluginId)': sqlDialect.identifierQuote is empty")
}
if dialect.keywords.isEmpty {
Self.logger.warning("Plugin '\(pluginId)': sqlDialect.keywords is empty")
}
}

private func replaceExistingPlugin(bundleId: String) {
guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return }
// Order matters: unregisterCapabilities reads from `plugins` to find the principal class
Expand Down Expand Up @@ -494,6 +567,16 @@ final class PluginManager {
return Swift.type(of: plugin).supportsQueryProgress
}

func supportsSSH(for databaseType: DatabaseType) -> Bool {
guard let plugin = driverPlugin(for: databaseType) else { return true }
return Swift.type(of: plugin).supportsSSH
}

func supportsSSL(for databaseType: DatabaseType) -> Bool {
guard let plugin = driverPlugin(for: databaseType) else { return true }
return Swift.type(of: plugin).supportsSSL
}

func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle {
guard let plugin = driverPlugin(for: databaseType) else { return .limit }
guard let dialect = Swift.type(of: plugin).sqlDialect else { return .none }
Expand Down
Loading
Loading