From b41eff17132323a0b8ac2432b0d59bb8a5696927 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 23 May 2026 16:46:50 +0700 Subject: [PATCH 1/2] fix(plugins): refetch the registry manifest before install and update so it never resolves against a stale cached list (#1380) --- CHANGELOG.md | 4 ++++ .../Core/Plugins/PluginManager+Install.swift | 2 ++ .../Plugins/Registry/RegistryClient.swift | 20 +++++++++++++++---- .../PluginManagerReconciliationTests.swift | 7 +++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0a5d639..cef7581d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304) +### Fixed + +- Installing or updating a plugin right after updating TablePro now refetches the current plugin list first, so it no longer fails against a stale cached list (the error a restart used to clear). (#1380) + ## [0.44.0] - 2026-05-23 ### Added diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index e53172eee..c0461a8e4 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -11,6 +11,7 @@ extension PluginManager { _ registryPlugin: RegistryPlugin, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginEntry { + let registryPlugin = await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) let binary = try validateRegistryCompatibility(registryPlugin) if plugins.contains(where: { $0.id == registryPlugin.id }) { throw PluginError.pluginConflict(existingName: registryPlugin.name) @@ -48,6 +49,7 @@ extension PluginManager { existingPluginLoaded: Bool = true, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginUpdateOutcome { + let registryPlugin = await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) let binary = try validateRegistryCompatibility(registryPlugin) if let existing = plugins.first(where: { $0.id == registryPlugin.id }), diff --git a/TablePro/Core/Plugins/Registry/RegistryClient.swift b/TablePro/Core/Plugins/Registry/RegistryClient.swift index e69172072..0969e49a2 100644 --- a/TablePro/Core/Plugins/Registry/RegistryClient.swift +++ b/TablePro/Core/Plugins/Registry/RegistryClient.swift @@ -114,10 +114,7 @@ final class RegistryClient { UserDefaults.standard.set(currentURL, forKey: Self.lastRegistryURLKey) } - var request = URLRequest(url: registryURL) - if !forceRefresh, let etag = cachedETag { - request.setValue(etag, forHTTPHeaderField: "If-None-Match") - } + let request = makeManifestRequest(forceRefresh: forceRefresh) do { let (data, response) = try await session.data(for: request) @@ -174,6 +171,21 @@ 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") + } + return request + } + + func refreshedPlugin(matching plugin: RegistryPlugin) async -> RegistryPlugin { + await fetchManifest(forceRefresh: true) + return manifest?.plugins.first { $0.id == plugin.id } ?? plugin + } + private func fallbackToCacheOrFail(message: String) { if manifest != nil { fetchState = .loaded diff --git a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift index ebd582a14..5f3c4b148 100644 --- a/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift +++ b/TableProTests/Core/Plugins/PluginManagerReconciliationTests.swift @@ -105,4 +105,11 @@ struct PluginManagerReconciliationTests { #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: false, retryRemaining: true)) #expect(!PluginManager.reconciliationShouldRetry(sawTransientFailure: false, retryRemaining: false)) } + + @Test("forceRefresh manifest request bypasses the local cache and sends no If-None-Match") + func forceRefreshRequestBypassesCache() { + let request = RegistryClient.shared.makeManifestRequest(forceRefresh: true) + #expect(request.cachePolicy == .reloadIgnoringLocalCacheData) + #expect(request.value(forHTTPHeaderField: "If-None-Match") == nil) + } } From daada26171d63406efe611f8be9150394d1d7896 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 24 May 2026 00:54:45 +0700 Subject: [PATCH 2/2] refactor(plugins): refresh the manifest once per reconciliation pass and claim in-flight before the network fetch (#1380) --- .../Core/Plugins/PluginManager+AutoUpdate.swift | 3 ++- TablePro/Core/Plugins/PluginManager+Install.swift | 14 +++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift index d89180ef8..c87632a61 100644 --- a/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift +++ b/TablePro/Core/Plugins/PluginManager+AutoUpdate.swift @@ -29,7 +29,7 @@ extension PluginManager { return } - await RegistryClient.shared.fetchManifest() + await RegistryClient.shared.fetchManifest(forceRefresh: true) refreshRegistryUpdateSet() guard let manifest = RegistryClient.shared.manifest else { reconciliationManifestAttempts += 1 @@ -92,6 +92,7 @@ extension PluginManager { let outcome = try await updateFromRegistry( registryPlugin, existingPluginLoaded: false, + refreshManifest: false, progress: { _ in } ) switch outcome { diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index c0461a8e4..ceed20ad1 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -11,8 +11,6 @@ extension PluginManager { _ registryPlugin: RegistryPlugin, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginEntry { - let registryPlugin = await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) - let binary = try validateRegistryCompatibility(registryPlugin) if plugins.contains(where: { $0.id == registryPlugin.id }) { throw PluginError.pluginConflict(existingName: registryPlugin.name) } @@ -24,6 +22,9 @@ extension PluginManager { installsInFlight.insert(registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } + let registryPlugin = await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) + let binary = try validateRegistryCompatibility(registryPlugin) + let userPluginsDir = self.userPluginsDir let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in if case .downloading(let fraction) = state { @@ -47,11 +48,9 @@ extension PluginManager { func updateFromRegistry( _ registryPlugin: RegistryPlugin, existingPluginLoaded: Bool = true, + refreshManifest: Bool = true, progress: @escaping @MainActor @Sendable (Double) -> Void ) async throws -> PluginUpdateOutcome { - let registryPlugin = await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) - let binary = try validateRegistryCompatibility(registryPlugin) - if let existing = plugins.first(where: { $0.id == registryPlugin.id }), existing.source == .builtIn { throw PluginError.pluginConflict(existingName: existing.name) @@ -65,6 +64,11 @@ extension PluginManager { installsInFlight.insert(registryPlugin.id) defer { installsInFlight.remove(registryPlugin.id) } + let registryPlugin = refreshManifest + ? await RegistryClient.shared.refreshedPlugin(matching: registryPlugin) + : registryPlugin + let binary = try validateRegistryCompatibility(registryPlugin) + let hasLive = pluginHasLiveConnections(registryPlugin) let userPluginsDir = self.userPluginsDir let stateHandler: @Sendable (StagedInstallState) async -> Void = { state in