From 52f9b8d345a3028406eead40c9d7cf0a6f979918 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 2 Jul 2026 20:17:28 +0700 Subject: [PATCH] fix(plugins): revalidate the plugin registry instead of serving a stale cached list (#1799) --- CHANGELOG.md | 2 + TablePro/AppDelegate.swift | 1 + TablePro/Core/Plugins/PluginError.swift | 3 + .../Core/Plugins/PluginManager+Install.swift | 3 +- .../Plugins/PluginManager+Registration.swift | 23 +- .../Plugins/Registry/RegistryClient.swift | 162 +++++++----- .../Settings/Plugins/BrowsePluginsView.swift | 128 +++++---- .../Plugins/InstalledPluginsView.swift | 4 +- .../RegistryClientFreshnessTests.swift | 246 ++++++++++++++++++ docs/development/plugin-registry.mdx | 4 +- 10 files changed, 451 insertions(+), 125 deletions(-) create mode 100644 TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bba4ffffd..21064fe33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index 0511dce84..9b468ad93 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -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]) { diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index 80c65280d..d97d4f250 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -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) @@ -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): diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index e3a218f4b..1fdffaf5b 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -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 }) { @@ -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 diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 7f4b9e679..0cdb1bf24 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -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 @@ -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 } + } } diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index 3fdda1822..400ccccbd 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -14,34 +14,34 @@ 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? + @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 @@ -49,71 +49,113 @@ final class RegistryClient { 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) @@ -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) @@ -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)") @@ -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 } @@ -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) @@ -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) } diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index 2622fc5cd..a2da5b979 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -26,9 +26,7 @@ struct BrowsePluginsView: View { var body: some View { mainContent .task { - if registryClient.fetchState == .idle { - await registryClient.fetchManifest() - } + await registryClient.ensureManifest(.ifStale) await downloadCountService.fetchCounts(for: registryClient.manifest) } .alert(errorTitle, isPresented: $showErrorAlert) { @@ -48,61 +46,97 @@ struct BrowsePluginsView: View { @ViewBuilder private var mainContent: some View { - switch registryClient.fetchState { - case .idle, .loading: - ProgressView() + if registryClient.manifest != nil { + loadedContent + } else { + switch registryClient.fetchState { + case .idle, .loading, .loaded, .loadedFromCache: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .failed(let message): + ContentUnavailableView { + Label("Failed to Load", systemImage: "wifi.slash") + } description: { + Text(message) + } actions: { + Button("Try Again") { + refreshRegistry() + } + .buttonStyle(.bordered) + } .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } - case .loaded: - let plugins = registryClient.search(query: searchText, category: selectedCategory) - HSplitView { - VStack(spacing: 0) { - HStack(spacing: 6) { - NativeSearchField(text: $searchText, placeholder: String(localized: "Search...")) - Picker("", selection: $selectedCategory) { - Text("All").tag(RegistryCategory?.none) - ForEach(RegistryCategory.allCases) { category in - Text(category.displayName).tag(RegistryCategory?.some(category)) - } + private var loadedContent: some View { + let plugins = registryClient.search(query: searchText, category: selectedCategory) + return HSplitView { + VStack(spacing: 0) { + HStack(spacing: 6) { + NativeSearchField(text: $searchText, placeholder: String(localized: "Search...")) + Picker("", selection: $selectedCategory) { + Text("All").tag(RegistryCategory?.none) + ForEach(RegistryCategory.allCases) { category in + Text(category.displayName).tag(RegistryCategory?.some(category)) } - .labelsHidden() - .fixedSize() } - .padding(.horizontal, 8) - .padding(.vertical, 6) - - if plugins.isEmpty { - ContentUnavailableView.search(text: searchText) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(plugins, selection: $selectedPluginId) { plugin in - browseRow(plugin) - .tag(plugin.id) - } - .listStyle(.inset) + .labelsHidden() + .fixedSize() + + Button { + refreshRegistry() + } label: { + Image(systemName: "arrow.clockwise") } + .buttonStyle(.borderless) + .disabled(registryClient.fetchState == .loading) + .help("Refresh plugin list") + .accessibilityLabel(String(localized: "Refresh plugin list")) } - .frame(minWidth: 200, idealWidth: 240, maxWidth: 280) + .padding(.horizontal, 8) + .padding(.vertical, 6) - detailContent - .frame(minWidth: 340) - } + if case .loadedFromCache(let reason) = registryClient.fetchState { + staleManifestBanner(reason) + } - case .failed(let message): - ContentUnavailableView { - Label("Failed to Load", systemImage: "wifi.slash") - } description: { - Text(message) - } actions: { - Button("Try Again") { - Task { - await registryClient.fetchManifest(forceRefresh: true) - await downloadCountService.fetchCounts(for: registryClient.manifest) + if plugins.isEmpty { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(plugins, selection: $selectedPluginId) { plugin in + browseRow(plugin) + .tag(plugin.id) } + .listStyle(.inset) } - .buttonStyle(.bordered) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 200, idealWidth: 240, maxWidth: 280) + + detailContent + .frame(minWidth: 340) + } + } + + private func staleManifestBanner(_ reason: String) -> some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + Text(String(format: String(localized: "Showing saved plugin list. %@"), reason)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + private func refreshRegistry() { + Task { + await registryClient.ensureManifest(.mustBeCurrent) + await downloadCountService.fetchCounts(for: registryClient.manifest) } } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index e7f3b7da9..174024330 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -43,9 +43,7 @@ struct InstalledPluginsView: View { } } .task { - if registryClient.fetchState == .idle { - await registryClient.fetchManifest() - } + await registryClient.ensureManifest(.ifStale) } .onDrop(of: [.fileURL], isTargeted: nil) { providers in guard let provider = providers.first, diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift new file mode 100644 index 000000000..8ab4fe11d --- /dev/null +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -0,0 +1,246 @@ +// +// RegistryClientFreshnessTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +private final class MockRegistryProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + nonisolated(unsafe) private static var queue: [(status: Int, body: Data)] = [] + nonisolated(unsafe) private static var received: [URLRequest] = [] + + static func reset(responses: [(status: Int, body: Data)]) { + lock.lock(); defer { lock.unlock() } + queue = responses + received = [] + } + + static var requestCount: Int { + lock.lock(); defer { lock.unlock() } + return received.count + } + + private static func next(recording request: URLRequest) -> (status: Int, body: Data) { + lock.lock(); defer { lock.unlock() } + received.append(request) + return queue.isEmpty ? (200, Data()) : queue.removeFirst() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func stopLoading() {} + + override func startLoading() { + let response = Self.next(recording: request) + guard let url = request.url, + let httpResponse = HTTPURLResponse( + url: url, statusCode: response.status, httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + ) + else { return } + client?.urlProtocol(self, didReceive: httpResponse, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } +} + +@Suite("RegistryClient freshness", .serialized) +@MainActor +struct RegistryClientFreshnessTests { + private static let testTypeId = "TestFreshnessDB" + + private func manifestData(includingTestPlugin: Bool) -> Data { + var plugins = "" + if includingTestPlugin { + plugins = """ + { + "id": "com.test.freshness-driver", + "name": "Freshness Test Driver", + "version": "1.0.0", + "summary": "test", + "author": {"name": "Tester"}, + "category": "database-driver", + "databaseTypeIds": ["\(Self.testTypeId)"], + "minAppVersion": "999.0.0", + "binaries": [ + {"architecture": "arm64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": 18}, + {"architecture": "x86_64", "downloadURL": "https://x", "sha256": "deadbeef", "pluginKitVersion": 18} + ] + } + """ + } + return Data("{\"schemaVersion\": 2, \"plugins\": [\(plugins)]}".utf8) + } + + private func makeEnvironment( + cachedManifest: Data? = nil + ) throws -> (client: RegistryClient, defaults: UserDefaults, tempDir: URL) { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("registry-freshness-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + let cacheURL = tempDir.appendingPathComponent("registry-manifest.json") + if let cachedManifest { + try cachedManifest.write(to: cacheURL) + } + + let suiteName = "registry-freshness-tests-\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw CocoaError(.fileNoSuchFile) + } + + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockRegistryProtocol.self] + let session = URLSession(configuration: config) + + let client = RegistryClient( + userDefaults: defaults, + session: session, + manifestCacheURL: cacheURL + ) + return (client, defaults, tempDir) + } + + @Test("non-forced manifest request revalidates with the origin instead of trusting the local cache") + func nonForcedRequestRevalidates() throws { + let (client, _, _) = try makeEnvironment() + let request = client.makeManifestRequest(forceRefresh: false) + #expect(request.cachePolicy == .reloadRevalidatingCacheData) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + } + + @Test("forced manifest request bypasses the local cache entirely") + func forcedRequestIgnoresCache() throws { + let (client, _, _) = try makeEnvironment() + let request = client.makeManifestRequest(forceRefresh: true) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + } + + @Test("ifStale skips the network inside the freshness window") + func ifStaleThrottlesInsideFreshnessWindow() async throws { + MockRegistryProtocol.reset(responses: [ + (200, manifestData(includingTestPlugin: false)), + (200, manifestData(includingTestPlugin: false)) + ]) + let (client, _, _) = try makeEnvironment() + + await client.ensureManifest(.ifStale) + #expect(MockRegistryProtocol.requestCount == 1) + #expect(client.fetchState == .loaded) + + await client.ensureManifest(.ifStale) + #expect(MockRegistryProtocol.requestCount == 1) + } + + @Test("mustBeCurrent revalidates even inside the freshness window") + func mustBeCurrentAlwaysRevalidates() async throws { + MockRegistryProtocol.reset(responses: [ + (200, manifestData(includingTestPlugin: false)), + (200, manifestData(includingTestPlugin: true)) + ]) + let (client, _, _) = try makeEnvironment() + + await client.ensureManifest(.ifStale) + #expect(MockRegistryProtocol.requestCount == 1) + + await client.ensureManifest(.mustBeCurrent) + #expect(MockRegistryProtocol.requestCount == 2) + #expect(client.manifest?.plugins.count == 1) + } + + @Test("concurrent callers share one in-flight fetch") + func concurrentCallersShareOneFetch() async throws { + MockRegistryProtocol.reset(responses: [ + (200, manifestData(includingTestPlugin: false)), + (200, manifestData(includingTestPlugin: false)) + ]) + let (client, _, _) = try makeEnvironment() + + async let first: Void = client.ensureManifest(.ifStale) + async let second: Void = client.ensureManifest(.ifStale) + _ = await (first, second) + + #expect(MockRegistryProtocol.requestCount == 1) + } + + @Test("failed refresh keeps the cached manifest and reports it as cached") + func failedRefreshKeepsCachedManifest() async throws { + MockRegistryProtocol.reset(responses: [(500, Data())]) + let (client, _, _) = try makeEnvironment(cachedManifest: manifestData(includingTestPlugin: true)) + + #expect(client.manifest != nil) + await client.ensureManifest(.mustBeCurrent) + + #expect(client.manifest?.plugins.count == 1) + guard case .loadedFromCache = client.fetchState else { + Issue.record("Expected loadedFromCache, got \(client.fetchState)") + return + } + } + + @Test("stale manifest miss triggers a fresh lookup before reporting not found") + func staleManifestMissRevalidatesBeforeNotFound() async throws { + let stale = manifestData(includingTestPlugin: false) + let fresh = manifestData(includingTestPlugin: true) + MockRegistryProtocol.reset(responses: [(200, stale), (200, fresh), (200, fresh)]) + let (client, defaults, tempDir) = try makeEnvironment(cachedManifest: stale) + + let manager = PluginManager( + userDefaults: defaults, + builtInPluginsURL: nil, + userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) + ) + + do { + try await manager.installMissingPlugin( + for: DatabaseType(rawValue: Self.testTypeId), + registryClient: client + ) { _ in } + Issue.record("Expected install to fail at compatibility validation") + } catch let error as PluginError { + if case .notFound = error { + Issue.record("Lookup missed a plugin the forced revalidation should have found") + } + guard case .incompatibleWithCurrentApp = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test("unreachable registry reports a connection problem, not a missing plugin") + func unreachableRegistryReportsConnectionProblem() async throws { + MockRegistryProtocol.reset(responses: [(500, Data()), (500, Data())]) + let (client, defaults, tempDir) = try makeEnvironment() + + let manager = PluginManager( + userDefaults: defaults, + builtInPluginsURL: nil, + userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) + ) + + do { + try await manager.installMissingPlugin( + for: DatabaseType(rawValue: Self.testTypeId), + registryClient: client + ) { _ in } + Issue.record("Expected install to fail") + } catch let error as PluginError { + guard case .registryUnreachable = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test("registryPlugin resolves by database type id") + func registryPluginLookup() throws { + let manifest = try JSONDecoder().decode(RegistryManifest.self, from: manifestData(includingTestPlugin: true)) + #expect(PluginManager.registryPlugin(forTypeId: Self.testTypeId, in: manifest) != nil) + #expect(PluginManager.registryPlugin(forTypeId: "SomethingElse", in: manifest) == nil) + #expect(PluginManager.registryPlugin(forTypeId: Self.testTypeId, in: nil) == nil) + } +} diff --git a/docs/development/plugin-registry.mdx b/docs/development/plugin-registry.mdx index 84025fa8e..2e43ab3f4 100644 --- a/docs/development/plugin-registry.mdx +++ b/docs/development/plugin-registry.mdx @@ -121,7 +121,7 @@ The registry serves the newest binary whose `pluginKitVersion` is in the app's r If a connection's driver is installed but its binary predates a breaking bump, opening the connection refreshes the registry and updates the driver in the background, then proceeds. The app does not block on this; if a compatible binary is not published yet, the connection reports that it is updating and retries on its own (on a timer and when the network returns), so no quit and reopen is needed. -The manifest is served through jsDelivr (`cdn.jsdelivr.net/gh/TableProApp/plugins@main/plugins.json`) and purged on each publish, so a new binary is visible within seconds rather than waiting out a CDN cache. +The manifest is served from the raw GitHub origin (`raw.githubusercontent.com/TableProApp/plugins/main/plugins.json`), which caches at the edge for five minutes. The app never trusts its local copy blindly: every fetch revalidates with a conditional request, the list refreshes at launch and when the plugin browser opens (throttled to one check per five minutes), and an install prompt forces a fresh fetch before reporting a plugin as missing. A newly published plugin is visible in the app within minutes. Drivers that most users reach for are bundled inside the app and ship in lockstep, so they never depend on the registry. Reserve registry-only distribution for the long tail, where a short, non-blocking update on first connect is acceptable. @@ -165,4 +165,4 @@ defaults write com.TablePro com.TablePro.customRegistryURL "https://your-registr defaults delete com.TablePro com.TablePro.customRegistryURL ``` -The app invalidates its ETag cache when the registry URL changes, forcing a fresh fetch on the next Browse visit. +HTTP caching keys on the full URL, so changing the registry URL takes effect on the next fetch with no manual cache invalidation.