// Copyright (c) 2022, OpenEmu Team // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the OpenEmu Team nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY OpenEmu Team ''AS IS'' AND ANY // EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL OpenEmu Team BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import Foundation import os.log public enum OEGameCorePluginError: Int, CustomNSError { case alreadyLoaded = -1000 case invalid = -1001 case outOfSupport = -1002 public static var errorDomain: String { "org.openemu.OpenEmuKit.OEPlugin" } } public class OEPlugin: NSObject { private static var pluginClasses: Set = [] private static var allPluginsByType: [String: NSMutableDictionary] = [:] private static var pluginsForPathsByType: [String: NSMutableDictionary] = [:] private static var pluginsForNamesByType: [String: NSMutableDictionary] = [:] public private(set) var url: URL public private(set) var name: String public private(set) var bundle: Bundle public private(set) var infoDictionary: [String: Any] public private(set) var version: String public private(set) var displayName: String private class func pluginsForNames(createIfNeeded create: Bool) -> NSMutableDictionary? { var plugins = pluginsForNamesByType[Self.pluginType] if plugins == nil && create { plugins = [:] pluginsForNamesByType[Self.pluginType] = plugins } return plugins } private class func pluginsForPaths(createIfNeeded create: Bool) -> NSMutableDictionary? { var plugins = pluginsForPathsByType[Self.pluginType] if plugins == nil && create { plugins = [:] pluginsForPathsByType[Self.pluginType] = plugins } return plugins } private class var pluginType: String { return NSStringFromClass(self) } class var pluginExtension: String { assertionFailure("+pluginExtension must be overriden") return "" } class var pluginFolder: String { assertionFailure("+pluginFolder must be overriden") return "" } override public var description: String { "Type: \(Self.pluginType), Bundle: \(displayName), Version: \(version), Path: \(url.path)" } required init(bundleAtURL bundleURL: URL, name: String?) throws { guard let bundle = Bundle(url: bundleURL), let infoDictionary = bundle.infoDictionary else { throw OEGameCorePluginError.invalid } let name = name ?? (bundleURL.lastPathComponent as NSString).deletingPathExtension let existing = Self.pluginsForNames(createIfNeeded: false)?[name] ?? Self.pluginsForPaths(createIfNeeded: false)?[bundleURL.path] if existing != nil { throw OEGameCorePluginError.alreadyLoaded } if bundleURL.pathExtension != Self.pluginExtension { throw OEGameCorePluginError.invalid } self.url = bundle.bundleURL self.name = name self.bundle = bundle self.infoDictionary = infoDictionary self.version = infoDictionary["CFBundleVersion"] as? String ?? "" self.displayName = infoDictionary["CFBundleName"] as? String ?? infoDictionary["CFBundleExecutable"] as? String ?? "" super.init() if isOutOfSupport { // plugin must be removed os_log(.default, log: .default, "Removing out-of-support plugin %{public}@", bundleURL.path) let fm = FileManager.default do { try fm.removeItem(at: bundleURL) } catch { os_log(.error, log: .default, "Error when removing out-of-support plugin: %{public}@", error as NSError) } throw OEGameCorePluginError.outOfSupport } Self.pluginsForPaths(createIfNeeded: true)![bundleURL.path] = self Self.pluginsForNames(createIfNeeded: true)![name] = self } deinit { bundle.unload() } public class func plugin(bundleAtURL bundleURL: URL, forceReload reload: Bool = false) throws -> Self? { var plugins = allPluginsByType[Self.pluginType] if plugins == nil { plugins = NSMutableDictionary() allPluginsByType[Self.pluginType] = plugins } let pluginName = (bundleURL.lastPathComponent as NSString).deletingPathExtension var ret = plugins?[pluginName] as? NSObject if reload { // Will override a previous failed attempt at loading a plugin if ret == NSNull() { ret = nil } // A plugin was already successfully loaded else if ret != nil { throw OEGameCorePluginError.alreadyLoaded } } // No plugin with such name, attempt to actually load the file at the given url if ret == nil { var err: Error? do { ret = try Self.init(bundleAtURL: bundleURL, name: pluginName) } catch { err = error } // If ret is still nil at this point, it means the plugin can't be loaded if ret == nil { ret = NSNull() } Self.willChangeValue(forKey: "allPlugins") plugins?[pluginName] = ret Self.didChangeValue(forKey: "allPlugins") if let error = err { throw error } } if ret == NSNull() { ret = nil } return ret as? Self } public class func registerClass() { pluginClasses.insert(Self.pluginType) _ = plugins() } class func plugins() -> [OEPlugin] { guard pluginClasses.contains(Self.pluginType) else { assertionFailure("\(pluginType) must be registered with +registerClass") return [] } var plugins = allPluginsByType[Self.pluginType] if plugins == nil { let fm = FileManager.default // load plugins in Application Support let appSupportDir: URL #if swift(>=5.7) if #available(macOS 13.0, *) { appSupportDir = .applicationSupportDirectory } else { appSupportDir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! } #else appSupportDir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! #endif let pluginsDir = appSupportDir.appendingPathComponent("OpenEmu", isDirectory: true) .appendingPathComponent(Self.pluginFolder, isDirectory: true) let pluginURLs = try? fm.contentsOfDirectory(at: pluginsDir, includingPropertiesForKeys: []) for bundleURL in pluginURLs ?? [] where bundleURL.pathExtension == Self.pluginExtension { _ = try? plugin(bundleAtURL: bundleURL, forceReload: true) } // load plugins in application bundle let builtInPluginsURL = Bundle.main.builtInPlugInsURL! let bundledPluginsDir = builtInPluginsURL.appendingPathComponent(Self.pluginFolder, isDirectory: true) let bundledPluginURLs = try? fm.contentsOfDirectory(at: bundledPluginsDir, includingPropertiesForKeys: []) for bundleURL in bundledPluginURLs ?? [] where bundleURL.pathExtension == Self.pluginExtension { _ = try? plugin(bundleAtURL: bundleURL) } plugins = allPluginsByType[Self.pluginType] } let val = plugins?.allValues.compactMap { $0 as? OEPlugin } ?? [] let set = Set(val) let ret = Array(set).sorted { $0.displayName.caseInsensitiveCompare($1.displayName) == .orderedAscending } return ret } var isDeprecated: Bool { return isOutOfSupport } var isOutOfSupport: Bool { return false } public func flushBundleCache() { bundle.flushBundleCache() infoDictionary = bundle.infoDictionary ?? infoDictionary version = infoDictionary["CFBundleVersion"] as? String ?? "" displayName = infoDictionary["CFBundleName"] as? String ?? infoDictionary["CFBundleExecutable"] as? String ?? "" } } extension OEPlugin: NSCopying { // When an instance is assigned as objectValue to an NSCell, the NSCell creates a copy. // Therefore we have to implement the NSCopying protocol // No need to make an actual copy, we can consider each OEPlugin instance like a singleton for their bundle public func copy(with zone: NSZone? = nil) -> Any { return self } }