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

- iOS: add data to a table from the Shortcuts app. Two new shortcuts, Add Row to Table and Add Rows to Table, pick a saved connection, database or schema, and table, then insert from JSON or CSV. They run without opening the app. (#1788)
- Refresh button in Settings > Plugins > Browse to reload the plugin list on demand. (#1799)

### Fixed

- Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796)
- The plugin list no longer goes stale. The app now checks the plugin registry for changes at launch, when the plugin browser opens, and before reporting a plugin as missing, so newly published plugins show up and install right away. A registry that cannot be reached now reports a connection problem instead of "Plugin not found". (#1799)
- Dropping a materialized view or foreign table from the sidebar now generates the correct DROP statement instead of DROP TABLE, and drop and truncate statements are schema-qualified. ClickHouse now lists materialized views as their own sidebar section and drops them with the DROP VIEW syntax it requires. (#1800)

## [0.54.0] - 2026-06-30
Expand Down
1 change: 1 addition & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
_ = InspectorDocumentController()
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
PluginManager.shared.loadPlugins()
Task { await RegistryClient.shared.ensureManifest(.ifStale) }
}

func application(_ application: NSApplication, open urls: [URL]) {
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 @@ -13,6 +13,7 @@ enum PluginError: LocalizedError {
case pluginOutdated(pluginVersion: Int, requiredVersion: Int)
case cannotUninstallBuiltIn
case notFound
case registryUnreachable
case noCompatibleBinary
case installFailed(String)
case pluginConflict(existingName: String)
Expand Down Expand Up @@ -40,6 +41,8 @@ enum PluginError: LocalizedError {
return String(localized: "Built-in plugins cannot be uninstalled")
case .notFound:
return String(localized: "Plugin not found")
case .registryUnreachable:
return String(localized: "Couldn't reach the plugin registry. Check your connection and try again.")
case .noCompatibleBinary:
return String(localized: "Plugin does not contain a compatible binary for this architecture")
case .installFailed(let reason):
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import os
extension PluginManager {
func installFromRegistry(
_ registryPlugin: RegistryPlugin,
registryClient: RegistryClient = .shared,
progress: @escaping @MainActor @Sendable (Double) -> Void
) async throws -> PluginEntry {
if plugins.contains(where: { $0.id == registryPlugin.id }) {
Expand All @@ -22,7 +23,7 @@ extension PluginManager {
installsInFlight.insert(registryPlugin.id)
defer { installsInFlight.remove(registryPlugin.id) }

let registryPlugin = await RegistryClient.shared.refreshedPlugin(matching: registryPlugin)
let registryPlugin = await registryClient.refreshedPlugin(matching: registryPlugin)
let binary = try validateRegistryCompatibility(registryPlugin)

let userPluginsDir = self.userPluginsDir
Expand Down
23 changes: 15 additions & 8 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ extension PluginManager {

func installMissingPlugin(
for databaseType: DatabaseType,
registryClient: RegistryClient = .shared,
progress: @escaping @MainActor @Sendable (Double) -> Void
) async throws {
let pluginTypeId = databaseType.pluginTypeId
Expand All @@ -546,20 +547,26 @@ extension PluginManager {
}
}

let registryClient = RegistryClient.shared
await registryClient.fetchManifest()
await registryClient.ensureManifest(.ifStale)
var registryPlugin = Self.registryPlugin(forTypeId: pluginTypeId, in: registryClient.manifest)

guard let manifest = registryClient.manifest else {
throw PluginError.downloadFailed(String(localized: "Could not fetch plugin registry"))
if registryPlugin == nil {
await registryClient.ensureManifest(.mustBeCurrent)
registryPlugin = Self.registryPlugin(forTypeId: pluginTypeId, in: registryClient.manifest)
}

guard let registryPlugin = manifest.plugins.first(where: { plugin in
plugin.databaseTypeIds?.contains(pluginTypeId) == true
}) else {
guard let registryPlugin else {
guard registryClient.fetchState == .loaded else {
throw PluginError.registryUnreachable
}
throw PluginError.notFound
}

let entry = try await installFromRegistry(registryPlugin, progress: progress)
let entry = try await installFromRegistry(registryPlugin, registryClient: registryClient, progress: progress)
Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'")
}

nonisolated static func registryPlugin(forTypeId pluginTypeId: String, in manifest: RegistryManifest?) -> RegistryPlugin? {
manifest?.plugins.first { $0.databaseTypeIds?.contains(pluginTypeId) == true }
}
}
162 changes: 98 additions & 64 deletions TablePro/Core/Plugins/Registry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,106 +14,148 @@ final class RegistryClient {
private(set) var fetchState: RegistryFetchState = .idle
private(set) var lastFetchDate: Date?

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

let session: URLSession
static let supportedSchemaVersion = 2
private static let logger = Logger(subsystem: "com.TablePro", category: "RegistryClient")
private static let manifestFreshnessWindow: TimeInterval = 300

private static let defaultRegistryURL = URL(string:
"https://cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json")!
"https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json")!

static let customRegistryURLKey = "com.TablePro.customRegistryURL"
private static let lastRegistryURLKey = "com.TablePro.lastRegistryURL"
private static let etagKey = "com.TablePro.registryETag"
private static let lastFetchKey = "com.TablePro.registryLastFetch"
private static let legacyManifestCacheKey = "registryManifestCache"
private static let legacyETagKey = "registryETag"
private static let legacyETagKeys = ["registryETag", "com.TablePro.registryETag"]
private static let legacyLastFetchKey = "registryLastFetch"
private static let legacyLastRegistryURLKey = "com.TablePro.lastRegistryURL"

private let defaults: UserDefaults
private let manifestCacheURL: URL

@ObservationIgnored private var inFlightFetch: Task<Void, Never>?
@ObservationIgnored private var lastFetchedURL: URL?

var isUsingCustomRegistry: Bool {
registryURL != Self.defaultRegistryURL
}

private var registryURL: URL {
if let raw = UserDefaults.standard.string(forKey: Self.customRegistryURLKey),
if let raw = defaults.string(forKey: Self.customRegistryURLKey),
let custom = URL(string: raw) {
Self.logger.warning("Using custom plugin registry URL: \(raw)")
return custom
}
return Self.defaultRegistryURL
}

private static let manifestCacheFileName = "registry-manifest.json"

private static var manifestCacheURL: URL? {
let fm = FileManager.default
guard let dir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
let bundleId = Bundle.main.bundleIdentifier ?? "com.TablePro"
return dir.appendingPathComponent(bundleId, isDirectory: true)
.appendingPathComponent(manifestCacheFileName)
init(
userDefaults: UserDefaults = .standard,
session: URLSession = RegistryClient.makeDefaultSession(),
manifestCacheURL: URL = RegistryClient.defaultManifestCacheURL()
) {
self.defaults = userDefaults
self.session = session
self.manifestCacheURL = manifestCacheURL
Self.migrateLegacyKeys(in: userDefaults)
migrateManifestCacheLocationIfNeeded()
loadCachedManifest()
}

private init() {
nonisolated static func makeDefaultSession() -> URLSession {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 30
config.waitsForConnectivity = true
self.session = URLSession(configuration: config)
config.urlCache = URLCache(
memoryCapacity: 1_000_000,
diskCapacity: 5_000_000,
directory: FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("TablePro/Registry/URLCache", isDirectory: true)
)
return URLSession(configuration: config)
}

Self.migrateLegacyKeys()
loadCachedManifest()
nonisolated static func defaultManifestCacheURL() -> URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("TablePro/Registry", isDirectory: true)
.appendingPathComponent(manifestCacheFileName)
}

private static func migrateLegacyKeys() {
let defaults = UserDefaults.standard
if defaults.object(forKey: legacyETagKey) != nil {
defaults.removeObject(forKey: legacyETagKey)
}
if defaults.object(forKey: legacyLastFetchKey) != nil {
defaults.removeObject(forKey: legacyLastFetchKey)
private static func migrateLegacyKeys(in defaults: UserDefaults) {
let obsoleteKeys = legacyETagKeys + [legacyLastFetchKey, legacyManifestCacheKey, legacyLastRegistryURLKey]
for key in obsoleteKeys where defaults.object(forKey: key) != nil {
defaults.removeObject(forKey: key)
}
if defaults.object(forKey: legacyManifestCacheKey) != nil {
defaults.removeObject(forKey: legacyManifestCacheKey)
}

private func migrateManifestCacheLocationIfNeeded() {
guard manifestCacheURL == Self.defaultManifestCacheURL() else { return }
let fm = FileManager.default
guard let cachesDir = fm.urls(for: .cachesDirectory, in: .userDomainMask).first else { return }
let bundleId = Bundle.main.bundleIdentifier ?? "com.TablePro"
let legacyURL = cachesDir.appendingPathComponent(bundleId, isDirectory: true)
.appendingPathComponent(Self.manifestCacheFileName)
guard fm.fileExists(atPath: legacyURL.path) else { return }
if !fm.fileExists(atPath: manifestCacheURL.path) {
do {
try fm.createDirectory(
at: manifestCacheURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try fm.copyItem(at: legacyURL, to: manifestCacheURL)
} catch {
Self.logger.warning("Failed to migrate registry manifest cache: \(error.localizedDescription)")
return
}
}
try? fm.removeItem(at: legacyURL)
}

private func loadCachedManifest() {
guard let url = Self.manifestCacheURL,
let data = try? Data(contentsOf: url),
guard let data = try? Data(contentsOf: manifestCacheURL),
let cached = try? JSONDecoder().decode(RegistryManifest.self, from: data)
else { return }
manifest = cached
lastFetchDate = UserDefaults.standard.object(forKey: Self.lastFetchKey) as? Date
lastFetchDate = defaults.object(forKey: Self.lastFetchKey) as? Date
}

private static func writeCachedManifest(_ data: Data) {
guard let url = manifestCacheURL else { return }
let dir = url.deletingLastPathComponent()
private func writeCachedManifest(_ data: Data) {
let dir = manifestCacheURL.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try data.write(to: url, options: .atomic)
try data.write(to: manifestCacheURL, options: .atomic)
} catch {
logger.warning("Failed to write registry cache: \(error.localizedDescription)")
Self.logger.warning("Failed to write registry cache: \(error.localizedDescription)")
}
}

// MARK: - Fetching

func ensureManifest(_ intent: RegistryFetchIntent) async {
if let inFlightFetch {
await inFlightFetch.value
return
}
if intent == .ifStale, !needsRefresh { return }
let task = Task { await fetchManifest() }
inFlightFetch = task
await task.value
inFlightFetch = nil
}

private var needsRefresh: Bool {
guard lastFetchedURL == registryURL, let lastFetchDate else { return true }
return Date().timeIntervalSince(lastFetchDate) > Self.manifestFreshnessWindow
}

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

let currentURL = registryURL.absoluteString
let lastURL = UserDefaults.standard.string(forKey: Self.lastRegistryURLKey)
if currentURL != lastURL {
cachedETag = nil
UserDefaults.standard.set(currentURL, forKey: Self.lastRegistryURLKey)
}

let request = makeManifestRequest(forceRefresh: forceRefresh)
if isUsingCustomRegistry {
Self.logger.warning("Using custom plugin registry URL: \(self.registryURL.absoluteString)")
}

do {
let (data, response) = try await session.data(for: request)
Expand All @@ -123,16 +165,6 @@ final class RegistryClient {
}

switch httpResponse.statusCode {
case 304:
Self.logger.debug("Registry manifest not modified (304)")
if manifest == nil {
Self.logger.warning("Got 304 but no cached manifest in memory; retrying without If-None-Match")
cachedETag = nil
await fetchManifest(forceRefresh: true)
return
}
fetchState = .loaded

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

Expand All @@ -148,10 +180,10 @@ final class RegistryClient {

manifest = decoded

Self.writeCachedManifest(data)
cachedETag = httpResponse.value(forHTTPHeaderField: "ETag")
writeCachedManifest(data)
lastFetchDate = Date()
UserDefaults.standard.set(lastFetchDate, forKey: Self.lastFetchKey)
lastFetchedURL = request.url
defaults.set(lastFetchDate, forKey: Self.lastFetchKey)

fetchState = .loaded
Self.logger.info("Fetched registry manifest with \(decoded.plugins.count) plugin(s)")
Expand All @@ -172,11 +204,7 @@ final class RegistryClient {

func makeManifestRequest(forceRefresh: Bool) -> URLRequest {
var request = URLRequest(url: registryURL)
if forceRefresh {
request.cachePolicy = .reloadIgnoringLocalCacheData
} else if let etag = cachedETag {
request.setValue(etag, forHTTPHeaderField: "If-None-Match")
}
request.cachePolicy = forceRefresh ? .reloadIgnoringLocalCacheData : .reloadRevalidatingCacheData
return request
}

Expand All @@ -187,7 +215,7 @@ final class RegistryClient {

private func fallbackToCacheOrFail(message: String) {
if manifest != nil {
fetchState = .loaded
fetchState = .loadedFromCache(message)
Self.logger.warning("Using cached registry manifest after fetch failure")
} else {
fetchState = .failed(message)
Expand Down Expand Up @@ -218,9 +246,15 @@ final class RegistryClient {
}
}

enum RegistryFetchIntent: Equatable, Sendable {
case ifStale
case mustBeCurrent
}

enum RegistryFetchState: Equatable, Sendable {
case idle
case loading
case loaded
case loadedFromCache(String)
case failed(String)
}
Loading
Loading