187 lines
7.1 KiB
Swift
187 lines
7.1 KiB
Swift
import Foundation
|
||
import MacToolsPluginKit
|
||
|
||
struct PluginPackageManifest: Codable, Equatable {
|
||
struct Capabilities: Codable, Equatable {
|
||
let primaryPanel: Bool
|
||
let componentPanel: Bool
|
||
let configuration: Bool
|
||
|
||
init(primaryPanel: Bool = false, componentPanel: Bool = false, configuration: Bool = false) {
|
||
self.primaryPanel = primaryPanel
|
||
self.componentPanel = componentPanel
|
||
self.configuration = configuration
|
||
}
|
||
}
|
||
|
||
let id: String
|
||
let displayName: String
|
||
let version: String
|
||
let minHostVersion: String
|
||
let pluginKitVersion: Int
|
||
let bundleRelativePath: String
|
||
let factoryClass: String?
|
||
let capabilities: Capabilities
|
||
let permissions: [String]
|
||
let category: String?
|
||
let releaseChannel: String?
|
||
let localizedMetadata: [String: PluginLocalizedMetadata]?
|
||
|
||
init(
|
||
id: String,
|
||
displayName: String,
|
||
version: String,
|
||
minHostVersion: String,
|
||
pluginKitVersion: Int = PluginPackageManifestLoader.supportedPluginKitVersion,
|
||
bundleRelativePath: String,
|
||
factoryClass: String? = nil,
|
||
capabilities: Capabilities = Capabilities(),
|
||
permissions: [String] = [],
|
||
category: String? = nil,
|
||
releaseChannel: String? = nil,
|
||
localizedMetadata: [String: PluginLocalizedMetadata]? = nil
|
||
) {
|
||
self.id = id
|
||
self.displayName = displayName
|
||
self.version = version
|
||
self.minHostVersion = minHostVersion
|
||
self.pluginKitVersion = pluginKitVersion
|
||
self.bundleRelativePath = bundleRelativePath
|
||
self.factoryClass = factoryClass
|
||
self.capabilities = capabilities
|
||
self.permissions = permissions
|
||
self.category = category
|
||
self.releaseChannel = releaseChannel
|
||
self.localizedMetadata = localizedMetadata
|
||
}
|
||
|
||
var localizedDisplayName: String {
|
||
PluginLocalizationMatcher.localizedMetadata(from: localizedMetadata ?? [:])?.displayName ?? displayName
|
||
}
|
||
|
||
var localizedSummary: String? {
|
||
PluginLocalizationMatcher.localizedMetadata(from: localizedMetadata ?? [:])?.summary
|
||
}
|
||
}
|
||
|
||
enum PluginPackageManifestError: LocalizedError, Equatable {
|
||
case missingManifest(URL)
|
||
case unreadableManifest(URL)
|
||
case invalidIdentifier(String)
|
||
case invalidVersion(String)
|
||
case invalidBundleRelativePath(String)
|
||
case unsupportedPluginKitVersion(Int)
|
||
case incompatibleHostVersion(required: String, current: String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case let .missingManifest(url):
|
||
return AppL10n.pluginsFormat("plugin.error.manifest.missingFormat", defaultValue: "插件缺少 manifest:%@", url.path)
|
||
case let .unreadableManifest(url):
|
||
return AppL10n.pluginsFormat("plugin.error.manifest.unreadableFormat", defaultValue: "插件 manifest 无法读取:%@", url.path)
|
||
case let .invalidIdentifier(id):
|
||
return AppL10n.pluginsFormat("plugin.error.manifest.invalidIdentifierFormat", defaultValue: "插件 ID 不合法:%@", id)
|
||
case let .invalidVersion(version):
|
||
return AppL10n.pluginsFormat("plugin.error.manifest.invalidVersionFormat", defaultValue: "插件版本号不合法:%@", version)
|
||
case let .invalidBundleRelativePath(path):
|
||
return AppL10n.pluginsFormat("plugin.error.manifest.invalidBundlePathFormat", defaultValue: "插件入口路径不合法:%@", path)
|
||
case let .unsupportedPluginKitVersion(version):
|
||
return AppL10n.pluginsFormat("plugin.error.manifest.unsupportedSDKFormat", defaultValue: "插件 SDK 版本不支持:%d", version)
|
||
case let .incompatibleHostVersion(required, current):
|
||
return AppL10n.pluginsFormat(
|
||
"plugin.error.manifest.incompatibleHostFormat",
|
||
defaultValue: "插件需要 MacTools %@ 或更高版本,当前版本为 %@。",
|
||
required,
|
||
current
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
enum PluginPackageManifestLoader {
|
||
static let fileName = "plugin.json"
|
||
static let supportedPluginKitVersion = PluginKitCompatibility.currentVersion
|
||
|
||
static func load(
|
||
from packageURL: URL,
|
||
hostVersion: String = AppMetadata.shortVersion ?? "0"
|
||
) throws -> PluginPackageManifest {
|
||
let manifest = try readManifest(from: packageURL)
|
||
try validate(manifest, hostVersion: hostVersion)
|
||
return manifest
|
||
}
|
||
|
||
static func decode(from packageURL: URL) throws -> PluginPackageManifest {
|
||
try readManifest(from: packageURL)
|
||
}
|
||
|
||
static func validate(_ manifest: PluginPackageManifest, hostVersion: String) throws {
|
||
try validatePackageIdentity(manifest)
|
||
|
||
guard manifest.pluginKitVersion == supportedPluginKitVersion else {
|
||
throw PluginPackageManifestError.unsupportedPluginKitVersion(manifest.pluginKitVersion)
|
||
}
|
||
|
||
guard PluginVersionComparator.isVersion(hostVersion, atLeast: manifest.minHostVersion) else {
|
||
throw PluginPackageManifestError.incompatibleHostVersion(
|
||
required: manifest.minHostVersion,
|
||
current: hostVersion
|
||
)
|
||
}
|
||
}
|
||
|
||
static func validatePackageIdentity(_ manifest: PluginPackageManifest) throws {
|
||
guard isValidPluginID(manifest.id) else {
|
||
throw PluginPackageManifestError.invalidIdentifier(manifest.id)
|
||
}
|
||
|
||
guard isValidVersion(manifest.version) else {
|
||
throw PluginPackageManifestError.invalidVersion(manifest.version)
|
||
}
|
||
|
||
guard isValidVersion(manifest.minHostVersion) else {
|
||
throw PluginPackageManifestError.invalidVersion(manifest.minHostVersion)
|
||
}
|
||
|
||
guard
|
||
!manifest.bundleRelativePath.isEmpty,
|
||
!manifest.bundleRelativePath.hasPrefix("/"),
|
||
!manifest.bundleRelativePath.split(separator: "/").contains("..")
|
||
else {
|
||
throw PluginPackageManifestError.invalidBundleRelativePath(manifest.bundleRelativePath)
|
||
}
|
||
}
|
||
|
||
private static func isValidPluginID(_ id: String) -> Bool {
|
||
let pattern = #"^[A-Za-z0-9][A-Za-z0-9._-]{1,126}[A-Za-z0-9]$"#
|
||
return id.range(of: pattern, options: .regularExpression) != nil
|
||
}
|
||
|
||
private static func isValidVersion(_ version: String) -> Bool {
|
||
let pattern = #"^[0-9]+(?:\.[0-9]+){0,2}$"#
|
||
return version.range(of: pattern, options: .regularExpression) != nil
|
||
}
|
||
|
||
private static func readManifest(from packageURL: URL) throws -> PluginPackageManifest {
|
||
let manifestURL = packageURL.appendingPathComponent(fileName, isDirectory: false)
|
||
|
||
guard FileManager.default.fileExists(atPath: manifestURL.path) else {
|
||
throw PluginPackageManifestError.missingManifest(manifestURL)
|
||
}
|
||
|
||
let data: Data
|
||
do {
|
||
data = try Data(contentsOf: manifestURL)
|
||
} catch {
|
||
throw PluginPackageManifestError.unreadableManifest(manifestURL)
|
||
}
|
||
|
||
do {
|
||
return try JSONDecoder().decode(PluginPackageManifest.self, from: data)
|
||
} catch {
|
||
throw PluginPackageManifestError.unreadableManifest(manifestURL)
|
||
}
|
||
}
|
||
|
||
}
|