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) } } }