Skip to content
8 changes: 4 additions & 4 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ enum PluginError: LocalizedError {
case cannotUninstallBuiltIn
case notFound
case registryUnreachable
case noCompatibleBinary
case noCompatibleRelease
case appTooOldForPlugin(oldestPublishedKit: Int, appKit: Int)
case installFailed(String)
case pluginConflict(existingName: String)
Expand Down Expand Up @@ -40,8 +40,8 @@ enum PluginError: LocalizedError {
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 .noCompatibleRelease:
return String(localized: "No build of this plugin works with this version of TablePro.")
case .appTooOldForPlugin:
return String(localized: "This plugin needs a newer version of TablePro. Update TablePro, then install it again.")
case .installFailed(let reason):
Expand Down Expand Up @@ -94,7 +94,7 @@ enum PluginError: LocalizedError {

var isPermanentReconciliationFailure: Bool {
switch self {
case .noCompatibleBinary, .appTooOldForPlugin, .incompatibleVersion:
case .noCompatibleRelease, .appTooOldForPlugin, .incompatibleVersion:
return true
default:
return false
Expand Down
28 changes: 13 additions & 15 deletions TablePro/Core/Plugins/PluginInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ actor PluginInstaller {

func install(
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
release: RegistryRelease,
into userPluginsDir: URL,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndCommit(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
progress: progress
)
Expand All @@ -30,7 +30,7 @@ actor PluginInstaller {

func update(
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
release: RegistryRelease,
into userPluginsDir: URL,
hasLiveConnections: Bool,
progress: @escaping @Sendable (Double) async -> Void
Expand All @@ -39,7 +39,7 @@ actor PluginInstaller {
let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndStage(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
progress: progress
)
Expand All @@ -51,7 +51,7 @@ actor PluginInstaller {
let finalURL = try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndCommit(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
progress: progress
)
Expand Down Expand Up @@ -113,13 +113,13 @@ actor PluginInstaller {

private func performDownloadAndCommit(
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
release: RegistryRelease,
into userPluginsDir: URL,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
let extracted = try await downloadExtractVerify(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
progress: progress
)
Expand All @@ -136,13 +136,13 @@ actor PluginInstaller {

private func performDownloadAndStage(
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
release: RegistryRelease,
into userPluginsDir: URL,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
let extracted = try await downloadExtractVerify(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
progress: progress
)
Expand All @@ -158,7 +158,7 @@ actor PluginInstaller {

private func downloadExtractVerify(
_ registryPlugin: RegistryPlugin,
binary: RegistryBinary,
release: RegistryRelease,
into userPluginsDir: URL,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> ExtractedBundle {
Expand All @@ -169,10 +169,8 @@ actor PluginInstaller {

let session = await MainActor.run { RegistryClient.shared.session }

// The registry manifest is fetched over the network, so the URL it names is untrusted
// input. The code-signature check is the real gate, but nothing should be fetched over
// cleartext on the way to it.
guard let downloadURL = URL(string: binary.downloadURL),
// The manifest is network input: the signature check is the real gate, but nothing travels over cleartext.
guard let downloadURL = URL(string: release.url),
downloadURL.scheme?.lowercased() == "https" else {
throw PluginError.downloadFailed("Invalid download URL")
}
Expand All @@ -183,7 +181,7 @@ actor PluginInstaller {
throw PluginError.downloadFailed("HTTP \(code)")
}

guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == binary.sha256.lowercased() else {
guard DownloadedBinary.sha256Hex(ofFileAt: tempDownloadURL.path) == release.sha256.lowercased() else {
throw PluginError.checksumMismatch
}

Expand Down
44 changes: 18 additions & 26 deletions TablePro/Core/Plugins/PluginManager+AutoUpdate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ extension PluginManager {
return
}

await RegistryClient.shared.fetchManifest(forceRefresh: true)
await RegistryClient.shared.fetch(policy: .mustBeCurrent)
refreshRegistryUpdateSet()
guard let manifest = RegistryClient.shared.manifest else {
reconciliationManifestAttempts += 1
Expand Down Expand Up @@ -94,11 +94,7 @@ extension PluginManager {
reconciliationAttempts[lookupId] = attempts + 1

do {
let outcome = try await updateFromRegistry(
registryPlugin,
refreshManifest: false,
progress: { _ in }
)
let outcome = try await updateFromRegistry(registryPlugin, progress: { _ in })
switch outcome {
case .installed:
refreshRegistryUpdateSet()
Expand All @@ -111,8 +107,8 @@ extension PluginManager {
return .resolved
} catch let error as PluginError where error.isPermanentReconciliationFailure {
let action = Self.rejectedAction(registryPlugin: registryPlugin, manifestLoaded: true, kits: Self.compatibleKits)
updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: registryPlugin))
if case .noCompatibleBinary = error, case .awaitingCompatibleBuild = action {
updateRejectedReason(url: rejected.url, reason: incompatibleBuildReason(for: action))
if case .noCompatibleRelease = error, case .awaitingCompatibleBuild = action {
Self.logger.warning("Reconciliation: no compatible build published yet for '\(rejected.name)', will retry")
return .transient(id: lookupId)
}
Expand Down Expand Up @@ -161,11 +157,8 @@ extension PluginManager {
)
}

private func incompatibleBuildReason(for registryPlugin: RegistryPlugin) -> String {
let availableKits = registryPlugin.binaries
.filter { $0.architecture == .current }
.compactMap(\.pluginKitVersion)
if availableKits.contains(where: { $0 > Self.currentPluginKitVersion }) {
private func incompatibleBuildReason(for action: RejectedPluginAction) -> String {
if case .requiresAppUpdate = action {
return String(localized: "A newer version of TablePro is required for this plugin. Update TablePro to keep using it.")
}
return String(localized: "No compatible build is available yet. This plugin will update automatically once one is published.")
Expand Down Expand Up @@ -202,13 +195,14 @@ extension PluginManager {
rejectedPlugins.removeAll { $0.url == url }
}

func registryUpdate(for pluginId: String) -> RegistryPlugin? {
func registryUpdate(for pluginId: String) -> (plugin: RegistryPlugin, release: RegistryRelease)? {
guard let manifest = RegistryClient.shared.manifest else { return nil }
guard let installed = plugins.first(where: { $0.id == pluginId }) else { return nil }
guard installed.source == .userInstalled else { return nil }
guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }) else { return nil }
return registryPlugin.version.compare(installed.version, options: .numeric) == .orderedDescending
? registryPlugin : nil
guard let registryPlugin = manifest.plugins.first(where: { $0.id == pluginId }),
let release = try? Self.newestRelease(of: registryPlugin),
release.build > installed.build else { return nil }
return (registryPlugin, release)
}

func refreshRegistryUpdateSet() {
Expand Down Expand Up @@ -242,16 +236,14 @@ extension PluginManager {
) -> RejectedPluginAction {
guard manifestLoaded else { return .awaitingCompatibleBuild }
guard let registryPlugin else { return .notInRegistry }
let availableKits = registryPlugin.binaries
.filter { $0.architecture == .current }
.compactMap(\.pluginKitVersion)
if availableKits.contains(where: { kits.contains($0) }) {
do {
_ = try newestRelease(of: registryPlugin, kits: kits)
return .updateAvailable(registryPlugin)
}
if availableKits.contains(where: { $0 > kits.upperBound }) {
} catch PluginError.appTooOldForPlugin {
return .requiresAppUpdate
} catch {
return .awaitingCompatibleBuild
}
return .awaitingCompatibleBuild
}

func hasOutdatedRejectedPlugin(forTypeId typeId: String) -> Bool {
Expand All @@ -270,7 +262,7 @@ extension PluginManager {
}
guard driverPlugin(for: type) == nil else { return }
if PluginMetadataRegistry.shared.snapshot(for: type) == nil {
await registryClient.ensureManifest(.ifStale)
await registryClient.fetch(policy: .ifStale)
}
let unavailability = driverUnavailability(for: type, registryManifest: registryClient.manifest)
guard unavailability == .notInstalled else {
Expand Down Expand Up @@ -304,7 +296,7 @@ extension PluginManager {
guard !targets.isEmpty else { return }
reconciliationActive = true
defer { reconciliationActive = false }
await RegistryClient.shared.fetchManifest(forceRefresh: true)
await RegistryClient.shared.fetch(policy: .mustBeCurrent)
guard let manifest = RegistryClient.shared.manifest else { return }
for target in targets {
if let lookupId = resolveRegistryId(for: target, manifest: manifest) {
Expand Down
28 changes: 14 additions & 14 deletions TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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 @@ -18,12 +17,11 @@ extension PluginManager {
installsInFlight.insert(registryPlugin.id)
defer { installsInFlight.remove(registryPlugin.id) }

let registryPlugin = await registryClient.refreshedPlugin(matching: registryPlugin)
let binary = try validateRegistryCompatibility(registryPlugin)
let release = try Self.newestRelease(of: registryPlugin)

let finalURL = try await PluginInstaller.shared.install(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
progress: { fraction in await progress(fraction) }
)
Expand All @@ -36,7 +34,6 @@ extension PluginManager {

func updateFromRegistry(
_ registryPlugin: RegistryPlugin,
refreshManifest: Bool = true,
progress: @escaping @MainActor @Sendable (Double) -> Void
) async throws -> PluginUpdateOutcome {
if let existing = plugins.first(where: { $0.id == registryPlugin.id }),
Expand All @@ -52,14 +49,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 release = try Self.newestRelease(of: registryPlugin)

let result = try await PluginInstaller.shared.update(
registryPlugin,
binary: binary,
release: release,
into: userPluginsDir,
hasLiveConnections: pluginHasLiveConnections(registryPlugin),
progress: { fraction in await progress(fraction) }
Expand All @@ -83,7 +77,7 @@ extension PluginManager {
)
PluginInstallTracker.shared.markStaged(
pluginId: registryPlugin.id,
newVersion: registryPlugin.version
newVersion: release.version
)
return .staged(pluginId: registryPlugin.id)
}
Expand Down Expand Up @@ -136,9 +130,15 @@ extension PluginManager {
PluginInstallTracker.shared.clearInstall(pluginId: pluginId)
}

@discardableResult
func validateRegistryCompatibility(_ registryPlugin: RegistryPlugin) throws -> RegistryBinary {
try registryPlugin.resolvedBinary(for: .current, kits: Self.compatibleKits)
nonisolated static func newestRelease(
of registryPlugin: RegistryPlugin,
kits: ClosedRange<Int> = compatibleKits
) throws -> RegistryRelease {
try registryPlugin.newestRelease(
channel: registryChannel,
kits: kits,
systemVersion: ProcessInfo.processInfo.operatingSystemVersion
)
}

func pluginHasLiveConnections(_ registryPlugin: RegistryPlugin) -> Bool {
Expand Down
7 changes: 3 additions & 4 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -758,16 +758,16 @@ extension PluginManager {
progress: @escaping @MainActor @Sendable (Double) -> Void,
lookup: (RegistryManifest?) -> RegistryPlugin?
) async throws -> PluginEntry {
await registryClient.ensureManifest(.ifStale)
await registryClient.fetch(policy: .ifStale)
var registryPlugin = lookup(registryClient.manifest)

if registryPlugin == nil {
await registryClient.ensureManifest(.mustBeCurrent)
await registryClient.fetch(policy: .mustBeCurrent)
registryPlugin = lookup(registryClient.manifest)
}

guard let registryPlugin else {
guard registryClient.fetchState == .loaded else {
guard registryClient.isManifestCurrent else {
throw PluginError.registryUnreachable
}
throw PluginError.notFound
Expand All @@ -779,7 +779,6 @@ extension PluginManager {
do {
let entry = try await installFromRegistry(
registryPlugin,
registryClient: registryClient,
progress: { fraction in
tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction)
progress(fraction)
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,9 @@ final class PluginManager: ObservableObject {

nonisolated internal static let compatibleKits = minimumCompatiblePluginKitVersion...currentPluginKitVersion

// A plugin matches the build it ships with, so a beta app takes beta plugins whatever the update setting.
nonisolated internal static let registryChannel: UpdateChannel = Bundle.main.isPrerelease ? .beta : .stable

nonisolated internal static func isCompatibleKit(_ kit: Int) -> Bool {
compatibleKits.contains(kit)
}
Expand Down
Loading
Loading