339 lines
14 KiB
Swift
339 lines
14 KiB
Swift
import Foundation
|
|
import MacToolsPluginKit
|
|
|
|
struct PluginPackageRecord: Identifiable, Equatable {
|
|
enum State: Equatable {
|
|
case enabled
|
|
case disabled
|
|
case incompatible(String)
|
|
case failed(String)
|
|
}
|
|
|
|
let id: String
|
|
let manifest: PluginPackageManifest
|
|
let packageURL: URL
|
|
let bundleURL: URL
|
|
let state: State
|
|
let requiresRestartToFullyUnload: Bool
|
|
}
|
|
|
|
enum PluginPackageStoreError: LocalizedError {
|
|
case packageNotFound(String)
|
|
case packageAlreadyInstalled(String)
|
|
case invalidPackage(URL)
|
|
case installFailed(String)
|
|
case removeFailed(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .packageNotFound(id):
|
|
return AppL10n.pluginsFormat("plugin.error.store.notFoundFormat", defaultValue: "未找到插件:%@", id)
|
|
case let .packageAlreadyInstalled(id):
|
|
return AppL10n.pluginsFormat("plugin.error.store.alreadyInstalledFormat", defaultValue: "插件已安装:%@", id)
|
|
case let .invalidPackage(url):
|
|
return AppL10n.pluginsFormat("plugin.error.store.invalidPackageFormat", defaultValue: "插件包无效:%@", url.path)
|
|
case let .installFailed(reason):
|
|
return AppL10n.pluginsFormat("plugin.error.store.installFailedFormat", defaultValue: "插件安装失败:%@", reason)
|
|
case let .removeFailed(reason):
|
|
return AppL10n.pluginsFormat("plugin.error.store.removeFailedFormat", defaultValue: "插件移除失败:%@", reason)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class PluginPackageStore {
|
|
private enum DefaultsKey {
|
|
static let disabledPluginIDs = "plugins.dynamic.disabledPluginIDs"
|
|
}
|
|
|
|
let rootDirectory: URL
|
|
let installedDirectory: URL
|
|
let stagingDirectory: URL
|
|
let dataDirectory: URL
|
|
let cacheDirectory: URL
|
|
let temporaryDirectory: URL
|
|
|
|
private let fileManager: FileManager
|
|
private let userDefaults: UserDefaults
|
|
let hostVersion: String
|
|
private var pendingRestartPluginIDs: Set<String> = []
|
|
|
|
init(
|
|
rootDirectory: URL? = nil,
|
|
fileManager: FileManager = .default,
|
|
userDefaults: UserDefaults = .standard,
|
|
hostVersion: String = AppMetadata.shortVersion ?? "0"
|
|
) {
|
|
self.fileManager = fileManager
|
|
self.userDefaults = userDefaults
|
|
self.hostVersion = hostVersion
|
|
|
|
let root = rootDirectory ?? Self.defaultRootDirectory(fileManager: fileManager)
|
|
self.rootDirectory = root
|
|
self.installedDirectory = root.appendingPathComponent("Installed", isDirectory: true)
|
|
self.stagingDirectory = root.appendingPathComponent("Staging", isDirectory: true)
|
|
self.dataDirectory = root.appendingPathComponent("Data", isDirectory: true)
|
|
self.cacheDirectory = root.appendingPathComponent("Caches", isDirectory: true)
|
|
self.temporaryDirectory = root.appendingPathComponent("Temporary", isDirectory: true)
|
|
|
|
createBaseDirectories()
|
|
}
|
|
|
|
func installedRecords() -> [PluginPackageRecord] {
|
|
createBaseDirectories()
|
|
|
|
let packageURLs = (try? fileManager.contentsOfDirectory(
|
|
at: installedDirectory,
|
|
includingPropertiesForKeys: [.isDirectoryKey],
|
|
options: [.skipsHiddenFiles]
|
|
)) ?? []
|
|
|
|
return packageURLs
|
|
.filter { $0.pathExtension == "mactoolsplugin" }
|
|
.compactMap { packageURL in
|
|
do {
|
|
let manifest = try PluginPackageManifestLoader.decode(from: packageURL)
|
|
try PluginPackageManifestLoader.validatePackageIdentity(manifest)
|
|
let bundleURL = packageURL.appendingPathComponent(manifest.bundleRelativePath)
|
|
let state: PluginPackageRecord.State
|
|
|
|
if manifest.pluginKitVersion != PluginPackageManifestLoader.supportedPluginKitVersion {
|
|
state = .incompatible(
|
|
AppL10n.pluginsFormat(
|
|
"plugin.error.store.installedSDKIncompatibleFormat",
|
|
defaultValue: "插件 SDK 版本不兼容,已安装版本为 %d,当前支持版本为 %d。请更新插件。",
|
|
manifest.pluginKitVersion,
|
|
PluginPackageManifestLoader.supportedPluginKitVersion
|
|
)
|
|
)
|
|
} else if !PluginVersionComparator.isVersion(hostVersion, atLeast: manifest.minHostVersion) {
|
|
state = .incompatible(
|
|
AppL10n.pluginsFormat(
|
|
"plugin.error.store.installedHostIncompatibleFormat",
|
|
defaultValue: "插件需要 MacTools %@ 或更高版本,当前版本为 %@。",
|
|
manifest.minHostVersion,
|
|
hostVersion
|
|
)
|
|
)
|
|
} else if fileManager.fileExists(atPath: bundleURL.path) {
|
|
state = disabledPluginIDs.contains(manifest.id) ? .disabled : .enabled
|
|
} else {
|
|
state = .failed(AppL10n.pluginsFormat(
|
|
"plugin.error.store.bundleMissingFormat",
|
|
defaultValue: "插件入口不存在:%@",
|
|
bundleURL.path
|
|
))
|
|
}
|
|
|
|
return PluginPackageRecord(
|
|
id: manifest.id,
|
|
manifest: manifest,
|
|
packageURL: packageURL,
|
|
bundleURL: bundleURL,
|
|
state: state,
|
|
requiresRestartToFullyUnload: pendingRestartPluginIDs.contains(manifest.id)
|
|
)
|
|
} catch {
|
|
let fallbackID = packageURL.deletingPathExtension().lastPathComponent
|
|
return PluginPackageRecord(
|
|
id: fallbackID,
|
|
manifest: PluginPackageManifest(
|
|
id: fallbackID,
|
|
displayName: fallbackID,
|
|
version: "0",
|
|
minHostVersion: "0",
|
|
bundleRelativePath: ""
|
|
),
|
|
packageURL: packageURL,
|
|
bundleURL: packageURL,
|
|
state: .failed(error.localizedDescription),
|
|
requiresRestartToFullyUnload: pendingRestartPluginIDs.contains(fallbackID)
|
|
)
|
|
}
|
|
}
|
|
.sorted { lhs, rhs in
|
|
lhs.manifest.localizedDisplayName.localizedCompare(rhs.manifest.localizedDisplayName) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
func installPackage(from sourceURL: URL, replaceExisting: Bool = false) throws -> PluginPackageRecord {
|
|
guard fileManager.fileExists(atPath: sourceURL.path) else {
|
|
throw PluginPackageStoreError.invalidPackage(sourceURL)
|
|
}
|
|
|
|
let manifest = try PluginPackageManifestLoader.load(from: sourceURL, hostVersion: hostVersion)
|
|
let destinationURL = installedDirectory
|
|
.appendingPathComponent(manifest.id, isDirectory: true)
|
|
.appendingPathExtension("mactoolsplugin")
|
|
let stagingURL = stagingDirectory
|
|
.appendingPathComponent("\(manifest.id)-\(UUID().uuidString)", isDirectory: true)
|
|
.appendingPathExtension("mactoolsplugin")
|
|
let backupURL = stagingDirectory
|
|
.appendingPathComponent("\(manifest.id)-backup-\(UUID().uuidString)", isDirectory: true)
|
|
.appendingPathExtension("mactoolsplugin")
|
|
let hadExistingPackage = fileManager.fileExists(atPath: destinationURL.path)
|
|
let wasDisabled = disabledPluginIDs.contains(manifest.id)
|
|
|
|
if hadExistingPackage {
|
|
guard replaceExisting else {
|
|
throw PluginPackageStoreError.packageAlreadyInstalled(manifest.id)
|
|
}
|
|
}
|
|
|
|
do {
|
|
if fileManager.fileExists(atPath: stagingURL.path) {
|
|
try fileManager.removeItem(at: stagingURL)
|
|
}
|
|
|
|
try fileManager.copyItem(at: sourceURL, to: stagingURL)
|
|
let stagedManifest = try PluginPackageManifestLoader.load(
|
|
from: stagingURL,
|
|
hostVersion: hostVersion
|
|
)
|
|
let stagedBundleURL = stagingURL.appendingPathComponent(stagedManifest.bundleRelativePath)
|
|
|
|
guard fileManager.fileExists(atPath: stagedBundleURL.path) else {
|
|
throw PluginPackageStoreError.invalidPackage(stagingURL)
|
|
}
|
|
|
|
if hadExistingPackage {
|
|
try fileManager.moveItem(at: destinationURL, to: backupURL)
|
|
}
|
|
|
|
try fileManager.moveItem(at: stagingURL, to: destinationURL)
|
|
try? fileManager.removeItem(at: backupURL)
|
|
} catch {
|
|
try? fileManager.removeItem(at: stagingURL)
|
|
if hadExistingPackage,
|
|
!fileManager.fileExists(atPath: destinationURL.path),
|
|
fileManager.fileExists(atPath: backupURL.path) {
|
|
try? fileManager.moveItem(at: backupURL, to: destinationURL)
|
|
} else {
|
|
try? fileManager.removeItem(at: backupURL)
|
|
}
|
|
throw PluginPackageStoreError.installFailed(error.localizedDescription)
|
|
}
|
|
|
|
if !hadExistingPackage || !wasDisabled {
|
|
setEnabled(true, for: manifest.id)
|
|
}
|
|
clearPendingRestart(pluginID: manifest.id)
|
|
|
|
guard let record = installedRecords().first(where: { $0.id == manifest.id }) else {
|
|
throw PluginPackageStoreError.installFailed(AppL10n.plugins(
|
|
"plugin.error.store.recordMissingAfterInstall",
|
|
defaultValue: "安装完成后无法读取插件记录。"
|
|
))
|
|
}
|
|
|
|
return record
|
|
}
|
|
|
|
func updatePackage(from sourceURL: URL) throws -> PluginPackageRecord {
|
|
try installPackage(from: sourceURL, replaceExisting: true)
|
|
}
|
|
|
|
func setEnabled(_ isEnabled: Bool, for pluginID: String) {
|
|
var ids = disabledPluginIDs
|
|
|
|
if isEnabled {
|
|
ids.remove(pluginID)
|
|
clearPendingRestart(pluginID: pluginID)
|
|
} else {
|
|
ids.insert(pluginID)
|
|
markPendingRestart(pluginID: pluginID)
|
|
}
|
|
|
|
disabledPluginIDs = ids
|
|
}
|
|
|
|
func markRequiresRestartToFullyUnload(pluginID: String) {
|
|
markPendingRestart(pluginID: pluginID)
|
|
}
|
|
|
|
func uninstall(pluginID: String, removeData: Bool) throws {
|
|
setEnabled(false, for: pluginID)
|
|
try removePackageFiles(pluginID: pluginID)
|
|
markPendingRestart(pluginID: pluginID)
|
|
|
|
if removeData {
|
|
try? fileManager.removeItem(at: dataDirectory.appendingPathComponent(pluginID, isDirectory: true))
|
|
try? fileManager.removeItem(at: cacheDirectory.appendingPathComponent(pluginID, isDirectory: true))
|
|
try? fileManager.removeItem(at: temporaryDirectory.appendingPathComponent(pluginID, isDirectory: true))
|
|
UserDefaultsPluginStorage.removeAllValues(pluginID: pluginID, userDefaults: userDefaults)
|
|
}
|
|
}
|
|
|
|
func runtimeContext(for record: PluginPackageRecord) -> PluginRuntimeContext {
|
|
let pluginID = record.id
|
|
|
|
let supportURL = dataDirectory.appendingPathComponent(pluginID, isDirectory: true)
|
|
let cacheURL = cacheDirectory.appendingPathComponent(pluginID, isDirectory: true)
|
|
let tempURL = temporaryDirectory.appendingPathComponent(pluginID, isDirectory: true)
|
|
|
|
[supportURL, cacheURL, tempURL].forEach { url in
|
|
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
|
}
|
|
|
|
return PluginRuntimeContext(
|
|
pluginID: pluginID,
|
|
resourceBundle: Bundle(url: record.bundleURL) ?? .main,
|
|
storage: UserDefaultsPluginStorage(pluginID: pluginID, userDefaults: userDefaults),
|
|
supportDirectory: supportURL,
|
|
cacheDirectory: cacheURL,
|
|
temporaryDirectory: tempURL
|
|
)
|
|
}
|
|
|
|
private func removePackageFiles(pluginID: String) throws {
|
|
let packageURL = installedDirectory
|
|
.appendingPathComponent(pluginID, isDirectory: true)
|
|
.appendingPathExtension("mactoolsplugin")
|
|
|
|
guard fileManager.fileExists(atPath: packageURL.path) else {
|
|
throw PluginPackageStoreError.packageNotFound(pluginID)
|
|
}
|
|
|
|
do {
|
|
try fileManager.removeItem(at: packageURL)
|
|
} catch {
|
|
throw PluginPackageStoreError.removeFailed(error.localizedDescription)
|
|
}
|
|
}
|
|
|
|
private func createBaseDirectories() {
|
|
[
|
|
rootDirectory,
|
|
installedDirectory,
|
|
stagingDirectory,
|
|
dataDirectory,
|
|
cacheDirectory,
|
|
temporaryDirectory
|
|
].forEach { url in
|
|
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)
|
|
}
|
|
}
|
|
|
|
private var disabledPluginIDs: Set<String> {
|
|
get {
|
|
Set(userDefaults.stringArray(forKey: DefaultsKey.disabledPluginIDs) ?? [])
|
|
}
|
|
set {
|
|
userDefaults.set(Array(newValue).sorted(), forKey: DefaultsKey.disabledPluginIDs)
|
|
}
|
|
}
|
|
|
|
private func markPendingRestart(pluginID: String) {
|
|
pendingRestartPluginIDs.insert(pluginID)
|
|
}
|
|
|
|
private func clearPendingRestart(pluginID: String) {
|
|
pendingRestartPluginIDs.remove(pluginID)
|
|
}
|
|
|
|
static func defaultRootDirectory(fileManager: FileManager) -> URL {
|
|
AppStorageScope.applicationSupportRoot(fileManager: fileManager)
|
|
.appendingPathComponent("Plugins", isDirectory: true)
|
|
}
|
|
}
|