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 @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Plugin system architecture — all 8 database drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) extracted into `.tableplugin` bundles loaded at runtime
- Settings > Plugins tab for plugin management — list installed plugins, enable/disable, install from file, uninstall user plugins, view plugin details
- Plugin marketplace — browse, search, and install plugins from the GitHub-hosted registry with SHA-256 checksum verification, ETag caching, and offline fallback
- TableProPluginKit framework — shared protocols and types for driver plugins
- ClickHouse database support with query progress tracking, EXPLAIN variants, TLS/HTTPS, server-side cancellation, and Parts view

Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ enum PluginError: LocalizedError {
case installFailed(String)
case pluginConflict(existingName: String)
case appVersionTooOld(minimumRequired: String, currentApp: String)
case downloadFailed(String)
case incompatibleWithCurrentApp(minimumRequired: String)

var errorDescription: String? {
switch self {
Expand All @@ -39,6 +41,10 @@ enum PluginError: LocalizedError {
return String(localized: "A built-in plugin \"\(existingName)\" already provides this bundle ID")
case .appVersionTooOld(let minimumRequired, let currentApp):
return String(localized: "Plugin requires app version \(minimumRequired) or later, but current version is \(currentApp)")
case .downloadFailed(let reason):
return String(localized: "Plugin download failed: \(reason)")
case .incompatibleWithCurrentApp(let minimumRequired):
return String(localized: "This plugin requires TablePro \(minimumRequired) or later")
}
}
}
54 changes: 54 additions & 0 deletions TablePro/Core/Plugins/Registry/PluginInstallTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// PluginInstallTracker.swift
// TablePro
//

import Foundation

@MainActor @Observable
final class PluginInstallTracker {
static let shared = PluginInstallTracker()

private(set) var activeInstalls: [String: InstallProgress] = [:]

private init() {}

func beginInstall(pluginId: String) {
activeInstalls[pluginId] = InstallProgress(phase: .downloading(fraction: 0))
}

func updateProgress(pluginId: String, fraction: Double) {
activeInstalls[pluginId]?.phase = .downloading(fraction: min(max(fraction, 0), 1))
}

func markInstalling(pluginId: String) {
activeInstalls[pluginId]?.phase = .installing
}

func completeInstall(pluginId: String) {
activeInstalls[pluginId]?.phase = .completed
}

func failInstall(pluginId: String, error: String) {
activeInstalls[pluginId]?.phase = .failed(error)
}

func clearInstall(pluginId: String) {
activeInstalls.removeValue(forKey: pluginId)
}

func state(for pluginId: String) -> InstallProgress? {
activeInstalls[pluginId]
}
}

struct InstallProgress: Equatable {
var phase: Phase

enum Phase: Equatable {
case downloading(fraction: Double)
case installing
case completed
case failed(String)
}
}
71 changes: 71 additions & 0 deletions TablePro/Core/Plugins/Registry/PluginManager+Registry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// PluginManager+Registry.swift
// TablePro
//

import CryptoKit
import Foundation

extension PluginManager {
func installFromRegistry(
_ registryPlugin: RegistryPlugin,
progress: @escaping @MainActor @Sendable (Double) -> Void
) async throws -> PluginEntry {
if let minAppVersion = registryPlugin.minAppVersion {
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
if appVersion.compare(minAppVersion, options: .numeric) == .orderedAscending {
throw PluginError.incompatibleWithCurrentApp(minimumRequired: minAppVersion)
}
}

if let minKit = registryPlugin.minPluginKitVersion, minKit > Self.currentPluginKitVersion {
throw PluginError.incompatibleVersion(required: minKit, current: Self.currentPluginKitVersion)
}

if plugins.contains(where: { $0.id == registryPlugin.id }) {
throw PluginError.pluginConflict(existingName: registryPlugin.name)
}

guard let downloadURL = URL(string: registryPlugin.downloadURL) else {
throw PluginError.downloadFailed("Invalid download URL")
}

let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let tempZipURL = tempDir.appendingPathComponent("\(registryPlugin.id).zip")

defer {
try? FileManager.default.removeItem(at: tempDir)
}

// Use the registry client's configured session for consistent timeouts
let session = await RegistryClient.shared.session

let (tempDownloadURL, response) = try await session.download(from: downloadURL)

guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? 0
throw PluginError.downloadFailed("HTTP \(statusCode)")
}

await progress(0.5)

// Verify SHA-256 checksum
let downloadedData = try Data(contentsOf: tempDownloadURL)
let digest = SHA256.hash(data: downloadedData)
let hexChecksum = digest.map { String(format: "%02x", $0) }.joined()

if hexChecksum != registryPlugin.sha256.lowercased() {
throw PluginError.checksumMismatch
}

await progress(1.0)

// Move to our temp directory for installPlugin
try FileManager.default.moveItem(at: tempDownloadURL, to: tempZipURL)

return try await installPlugin(from: tempZipURL)
}
}
132 changes: 132 additions & 0 deletions TablePro/Core/Plugins/Registry/RegistryClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// RegistryClient.swift
// TablePro
//

import Foundation
import os

@MainActor @Observable
final class RegistryClient {
static let shared = RegistryClient()

private(set) var manifest: RegistryManifest?
private(set) var fetchState: RegistryFetchState = .idle
private(set) var lastFetchDate: Date?

private var cachedETag: String? {
get { UserDefaults.standard.string(forKey: "registryETag") }
set { UserDefaults.standard.set(newValue, forKey: "registryETag") }
}

let session: URLSession
private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient")

// swiftlint:disable:next force_unwrapping
private static let registryURL = URL(string:
"https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")!

private static let manifestCacheKey = "registryManifestCache"
private static let lastFetchKey = "registryLastFetch"

private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
config.waitsForConnectivity = true
self.session = URLSession(configuration: config)

if let cachedData = UserDefaults.standard.data(forKey: Self.manifestCacheKey),
let cached = try? JSONDecoder().decode(RegistryManifest.self, from: cachedData) {
manifest = cached
lastFetchDate = UserDefaults.standard.object(forKey: Self.lastFetchKey) as? Date
}
}

// MARK: - Fetching

func fetchManifest(forceRefresh: Bool = false) async {
fetchState = .loading

var request = URLRequest(url: Self.registryURL)
if !forceRefresh, let etag = cachedETag {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}

do {
let (data, response) = try await session.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}

switch httpResponse.statusCode {
case 304:
Self.logger.debug("Registry manifest not modified (304)")
fetchState = .loaded

case 200...299:
let decoded = try JSONDecoder().decode(RegistryManifest.self, from: data)
manifest = decoded

UserDefaults.standard.set(data, forKey: Self.manifestCacheKey)
cachedETag = httpResponse.value(forHTTPHeaderField: "ETag")
lastFetchDate = Date()
UserDefaults.standard.set(lastFetchDate, forKey: Self.lastFetchKey)

fetchState = .loaded
Self.logger.info("Fetched registry manifest with \(decoded.plugins.count) plugin(s)")

default:
let message = HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode)
Self.logger.error("Registry fetch failed: HTTP \(httpResponse.statusCode) \(message)")
fallbackToCacheOrFail(message: "Server returned HTTP \(httpResponse.statusCode)")
}
} catch is DecodingError {
Self.logger.error("Failed to decode registry manifest")
fallbackToCacheOrFail(message: String(localized: "Failed to parse plugin registry"))
} catch {
Self.logger.error("Registry fetch failed: \(error.localizedDescription)")
fallbackToCacheOrFail(message: error.localizedDescription)
}
}

private func fallbackToCacheOrFail(message: String) {
if manifest != nil {
fetchState = .loaded
Self.logger.warning("Using cached registry manifest after fetch failure")
} else {
fetchState = .failed(message)
}
}

// MARK: - Search

func search(query: String, category: RegistryCategory?) -> [RegistryPlugin] {
guard let plugins = manifest?.plugins else { return [] }

var filtered = plugins

if let category {
filtered = filtered.filter { $0.category == category }
}

if !query.isEmpty {
let lowercased = query.lowercased()
filtered = filtered.filter { plugin in
plugin.name.lowercased().contains(lowercased)
|| plugin.summary.lowercased().contains(lowercased)
|| plugin.author.name.lowercased().contains(lowercased)
}
}

return filtered
}
}

enum RegistryFetchState: Equatable, Sendable {
case idle
case loading
case loaded
case failed(String)
}
52 changes: 52 additions & 0 deletions TablePro/Core/Plugins/Registry/RegistryModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// RegistryModels.swift
// TablePro
//

import Foundation

struct RegistryManifest: Codable, Sendable {
let schemaVersion: Int
let plugins: [RegistryPlugin]
}

struct RegistryPlugin: Codable, Sendable, Identifiable {
let id: String
let name: String
let version: String
let summary: String
let author: RegistryAuthor
let homepage: String?
let category: RegistryCategory
let downloadURL: String
let sha256: String
let minAppVersion: String?
let minPluginKitVersion: Int?
let iconName: String?
let isVerified: Bool
}

struct RegistryAuthor: Codable, Sendable {
let name: String
let url: String?
}

enum RegistryCategory: String, Codable, Sendable, CaseIterable, Identifiable {
case databaseDriver = "database-driver"
case exportFormat = "export-format"
case importFormat = "import-format"
case theme = "theme"
case other = "other"

var id: String { rawValue }

var displayName: String {
switch self {
case .databaseDriver: String(localized: "Database Drivers")
case .exportFormat: String(localized: "Export Formats")
case .importFormat: String(localized: "Import Formats")
case .theme: String(localized: "Themes")
case .other: String(localized: "Other")
}
}
}
Loading