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
9 changes: 0 additions & 9 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,4 @@ enum PluginError: LocalizedError {
if case .pluginOutdated = self { return true }
return false
}

var isPermanentReconciliationFailure: Bool {
switch self {
case .noCompatibleRelease, .appTooOldForPlugin, .incompatibleVersion:
return true
default:
return false
}
}
}
159 changes: 50 additions & 109 deletions TablePro/Core/Plugins/PluginInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,116 +2,32 @@ import Foundation
import os
import TableProUtilities

actor PluginInstaller {
static let shared = PluginInstaller()

static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstaller")

private var activeTasks: [String: Task<URL, Error>] = [:]
private var stagedUpdates: [String: URL] = [:]

private init() {}

protocol RegistryPluginInstalling: Sendable {
func install(
_ registryPlugin: RegistryPlugin,
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,
release: release,
into: userPluginsDir,
progress: progress
)
}
}
) async throws -> URL

func update(
func stage(
_ registryPlugin: RegistryPlugin,
release: RegistryRelease,
pluginId: String,
bundleName: String,
into userPluginsDir: URL,
hasLiveConnections: Bool,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> PluginUpdateResult {
if hasLiveConnections {
let stagedURL = try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndStage(
registryPlugin,
release: release,
into: userPluginsDir,
progress: progress
)
}
stagedUpdates[registryPlugin.id] = stagedURL
return .staged(at: stagedURL)
}

let finalURL = try await runCoalesced(pluginId: registryPlugin.id) {
try await self.performDownloadAndCommit(
registryPlugin,
release: release,
into: userPluginsDir,
progress: progress
)
}
return .installed(pluginURL: finalURL)
}

func commitStagedUpdate(pluginId: String, into userPluginsDir: URL) async throws -> URL {
guard let stagedURL = stagedUpdates[pluginId] else {
throw PluginError.notFound
}
guard let stagedBundle = Bundle(url: stagedURL) else {
throw PluginError.invalidBundle("Cannot create bundle from \(stagedURL.lastPathComponent)")
}
try PluginCodeSignatureVerifier.verify(bundle: stagedBundle)
let bundleName = stagedURL.deletingPathExtension().lastPathComponent
let destURL = userPluginsDir.appendingPathComponent("\(bundleName).tableplugin", isDirectory: true)
let finalURL = try Self.atomicReplace(stagedBundleURL: stagedURL, destURL: destURL)
stagedUpdates.removeValue(forKey: pluginId)
try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent())
return finalURL
}

func discardStagedUpdate(pluginId: String) {
guard let stagedURL = stagedUpdates.removeValue(forKey: pluginId) else { return }
try? FileManager.default.removeItem(at: stagedURL.deletingLastPathComponent())
}

func hasStagedUpdate(pluginId: String) -> Bool {
stagedUpdates[pluginId] != nil
}

func stagedURL(for pluginId: String) -> URL? {
stagedUpdates[pluginId]
}

func cancelInstall(pluginId: String) {
activeTasks[pluginId]?.cancel()
}
) async throws -> URL
}

// MARK: - Coalescing
actor PluginInstaller: RegistryPluginInstalling {
static let shared = PluginInstaller()

private func runCoalesced(
pluginId: String,
body: @Sendable @escaping () async throws -> URL
) async throws -> URL {
if let existing = activeTasks[pluginId] {
return try await existing.value
}
let task = Task<URL, Error> {
try await body()
}
activeTasks[pluginId] = task
defer { activeTasks.removeValue(forKey: pluginId) }
return try await task.value
}
static let logger = Logger(subsystem: "com.TablePro", category: "PluginInstaller")

// MARK: - Download + commit (no live connections)
private init() {}

private func performDownloadAndCommit(
func install(
_ registryPlugin: RegistryPlugin,
release: RegistryRelease,
into userPluginsDir: URL,
Expand All @@ -123,20 +39,17 @@ actor PluginInstaller {
into: userPluginsDir,
progress: progress
)

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

let destURL = userPluginsDir.appendingPathComponent(extracted.bundleURL.lastPathComponent, isDirectory: true)
return try Self.atomicReplace(stagedBundleURL: extracted.bundleURL, destURL: destURL)
}

// MARK: - Download + stage (live connections)

private func performDownloadAndStage(
func stage(
_ registryPlugin: RegistryPlugin,
release: RegistryRelease,
pluginId: String,
bundleName: String,
into userPluginsDir: URL,
progress: @escaping @Sendable (Double) async -> Void
) async throws -> URL {
Expand All @@ -146,7 +59,14 @@ actor PluginInstaller {
into: userPluginsDir,
progress: progress
)
return extracted.bundleURL
defer { try? FileManager.default.removeItem(at: extracted.workingDir) }

return try Self.stage(
bundleAt: extracted.bundleURL,
pluginId: pluginId,
bundleName: bundleName,
userPluginsDir: userPluginsDir
)
}

// MARK: - Download / extract / verify / quarantine-strip
Expand Down Expand Up @@ -232,6 +152,32 @@ actor PluginInstaller {
.appendingPathComponent("PluginStaging", isDirectory: true)
}

nonisolated static func stagedUpdatesRoot(for userPluginsDir: URL) -> URL {
stagingRoot(for: userPluginsDir).appendingPathComponent("Updates", isDirectory: true)
}

nonisolated static func stagedUpdateDirectory(for pluginId: String, in userPluginsDir: URL) -> URL {
stagedUpdatesRoot(for: userPluginsDir).appendingPathComponent(pluginId, isDirectory: true)
}

@discardableResult
nonisolated static func stage(
bundleAt bundleURL: URL,
pluginId: String,
bundleName: String,
userPluginsDir: URL
) throws -> URL {
let fm = FileManager.default
let stagedDir = stagedUpdateDirectory(for: pluginId, in: userPluginsDir)
if fm.fileExists(atPath: stagedDir.path) {
try fm.removeItem(at: stagedDir)
}
try fm.createDirectory(at: stagedDir, withIntermediateDirectories: true)
let stagedURL = stagedDir.appendingPathComponent(bundleName, isDirectory: true)
try fm.moveItem(at: bundleURL, to: stagedURL)
return stagedURL
}

nonisolated static func extractZip(at zipURL: URL, into destDir: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
Expand Down Expand Up @@ -317,8 +263,3 @@ private final class DownloadProgressObserver: NSObject, URLSessionTaskDelegate,
continuation.finish()
}
}

enum PluginUpdateResult: Sendable {
case installed(pluginURL: URL)
case staged(at: URL)
}
Loading
Loading