1import logging 2import os 3import platform 4import subprocess 5import traceback 6from configparser import ConfigParser, NoSectionError 7from operator import attrgetter 8 9import fsboot 10from fsbc.util import Version 11 12from fsbc.system import System, windows, linux 13from fsgs.FSGSDirectories import FSGSDirectories 14 15X86_MACHINES = ["x86", "i386", "i486", "i586", "i686"] 16X86_64_MACHINES = ["x86_64", "x86-64", "amd64"] 17X86_ANY_MACHINES = X86_MACHINES + X86_64_MACHINES 18 19logger = logging.getLogger("PLUGINS") 20 21known_plugin_versions = { 22 "CAPSImg": "5.1fs3", 23 "Cheats": "1.0.0", 24 "DOSBox-FS": "0.74.4006fs7", 25 "Fuse-FS": "1.3.3fs5", 26 "GenesisPlusGX-LR": "1.7.4git", 27 "Hatari-FS": "2.0.0fs1", 28 "MAME-FS": "0.189fs1", 29 "Mednafen-FS": "1.22.2fs0", 30 "Mupen64Plus-LR": "2.5fs0git", 31 "Nestopia-LR": "1.49fs0wip", 32 "QEMU-UAE": "3.8.2qemu2.2.0", 33 "Regina-FS": "3.9.1fs0", 34 "RetroArch-FS": "1.6.7fs1", 35 "UADE-FS": "2.13fs1", 36 "Vice-FS": "3.3fs0", 37} 38 39 40class BasePlugin: 41 def __init__(self, path, name, version="0.0.0"): 42 self.path = path 43 self.name = name 44 self.version = version 45 self.provides = {} 46 # outdated being None implies that it is unknown 47 self.outdated = None 48 known_version = known_plugin_versions.get(self.name, None) 49 if known_version: 50 try: 51 self.outdated = Version(self.version) < Version(known_version) 52 except ValueError: 53 pass 54 55 def add_provide(self, key, value): 56 logger.debug("%s -> %s", key, value) 57 self.provides[key] = value 58 59 def data_file_path(self, name): 60 return os.path.join(self.path, "Data", name) 61 62 63class Plugin(BasePlugin): 64 def __init__(self, path, name, version, cp): 65 super().__init__(path, name, version) 66 self.load_provides(cp) 67 68 def load_provides(self, cp): 69 logger.debug("loading provides for %s %s", self.path, self.platform()) 70 try: 71 for key, value in cp.items(self.platform()): 72 self._provides[key] = value 73 except NoSectionError: 74 pass 75 76 def __str__(self): 77 return "<Plugin {0}".format(self.path) 78 79 @staticmethod 80 def os_name(pretty=False): 81 if windows: 82 if pretty: 83 return "Windows" 84 return "windows" 85 elif System.macos: 86 if pretty: 87 return "macOS" 88 return "macos" 89 elif linux: 90 # if os.environ.get("STEAM_RUNTIME", ""): 91 # if pretty: 92 # return "SteamOS" 93 # return "steamos" 94 # else: 95 if pretty: 96 return "Linux" 97 return "linux" 98 else: 99 if pretty: 100 return "Unknown" 101 return "unknown" 102 103 @staticmethod 104 def arch_name(pretty=False): 105 if platform.machine().lower() in X86_ANY_MACHINES: 106 if platform.architecture()[0] == "64bit": 107 return "x86-64" 108 else: 109 return "x86" 110 else: 111 if pretty: 112 return "Unknown" 113 return "unknown" 114 115 @classmethod 116 def platform(cls): 117 return "{}_{}".format(cls.os_name(), cls.arch_name()) 118 119 120class Expansion(BasePlugin): 121 def __init__(self, path, arch_path, version=None): 122 name = os.path.basename(path) 123 version_path = os.path.join(arch_path, "Version.txt") 124 if os.path.exists(version_path): 125 with open(version_path, "r", encoding="UTF-8") as f: 126 version = f.read().strip() 127 else: 128 # version_path = os.path.join(path, "Data", "Version.txt") 129 version_path = os.path.join(path, "Version.txt") 130 with open(version_path, "r", encoding="UTF-8") as f: 131 version = f.read().strip() 132 super().__init__(path, name, version) 133 self._arch_path = arch_path 134 135 def load_provides(self): 136 logger.debug("Loading provides for %s", repr(self.path)) 137 if os.path.exists(self._arch_path): 138 self.load_arch_provides() 139 self.load_sha1_provides() 140 141 def load_sha1_provides(self): 142 data_dir = os.path.join(self.path, "Data") 143 sha1sums_file = os.path.join(data_dir, "SHA1SUMS") 144 if os.path.exists(sha1sums_file): 145 with open(sha1sums_file, "r", encoding="UTF-8") as f: 146 for line in f.readlines(): 147 line = line.strip() 148 if not line: 149 continue 150 sha1, relative_path = line.split(" ", 1) 151 relative_path = relative_path.lstrip("*") 152 path = os.path.join(data_dir, relative_path) 153 self.add_provide("sha1:" + sha1, path) 154 155 def load_arch_provides(self): 156 for item in os.listdir(self._arch_path): 157 path = os.path.join(self._arch_path, item) 158 if windows: 159 if item.endswith(".exe"): 160 self.add_provide("executable:" + item[:-4].lower(), path) 161 elif path.endswith(".dll"): 162 self.add_provide("library:" + item.lower()[:-4], path) 163 else: 164 if path.endswith(".so"): 165 self.add_provide("library:" + item.lower()[:-3], path) 166 elif os.access(path, os.X_OK): 167 self.add_provide("executable:" + item.lower(), path) 168 if System.macos: 169 if path.endswith(".app"): 170 app_path = os.path.join(path, "Contents", "MacOS") 171 if os.path.exists(app_path): 172 for app_item in os.listdir(app_path): 173 p = os.path.join(app_path, app_item) 174 if os.access(p, os.X_OK): 175 self.add_provide( 176 "executable:" + app_item.lower(), p 177 ) 178 179 def executable(self, name): 180 path = self.provides["executable:" + name] 181 return PluginExecutable(self, path) 182 183 def library_path(self, name): 184 path = self.provides["library:" + name] 185 return path 186 187 def sha1_path(self, sha1): 188 path = self.provides["sha1:" + sha1] 189 return path 190 191 def __str__(self): 192 return "<Expansion {0}".format(self.path) 193 194 195class PluginResource: 196 def __init__(self, plugin, name): 197 self.plugin = plugin 198 self.name = name 199 200 @property 201 def path(self): 202 p = os.path.join(self.plugin.path, self.plugin.provides[self.name]) 203 return p 204 205 206class Executable: 207 def __init__(self, path): 208 self.path = path 209 210 def popen(self, args, env=None, **kwargs): 211 logger.info("[EXECUTE] %s %s", self.path, repr(args)) 212 # logger.debug("PluginExecutable.popen %s %s %s", 213 # repr(args), repr(env), repr(kwargs)) 214 args = [self.path] + args 215 if os.environ.get("FSGS_STRACE", "") == "1": 216 args.insert(0, "strace") 217 if env: 218 env = env.copy() 219 else: 220 env = os.environ.copy() 221 222 if os.environ.get("FS_PLUGINS_LD_LIBRARY_PATH") == "1": 223 env["LD_LIBRARY_PATH"] = os.path.dirname(self.path) 224 225 return subprocess.Popen(args, env=env, **kwargs) 226 227 228class PluginExecutable(Executable): 229 def __init__(self, plugin, path): 230 super().__init__(path) 231 self.plugin = plugin 232 233 234class PluginManager: 235 @classmethod 236 def instance(cls): 237 if not hasattr(cls, "_instance") or cls._instance is None: 238 cls._instance = cls() 239 return cls._instance 240 241 @classmethod 242 def plugin_path(cls): 243 result = [] 244 plugins_dir = FSGSDirectories.get_plugins_dir() 245 result.append(plugins_dir) 246 247 # Plugins dir location has changed, add several old and new paths here 248 # to find plugins in both places (FS-UAE and OpenRetro style). 249 plugins_dir = os.path.join(FSGSDirectories.get_base_dir(), "Plugins") 250 if plugins_dir not in result: 251 result.append(plugins_dir) 252 plugins_dir = os.path.join(FSGSDirectories.get_data_dir(), "Plugins") 253 if plugins_dir not in result: 254 result.append(plugins_dir) 255 256 # if plugins_dir and os.path.isdir(plugins_dir): 257 # result.append(plugins_dir) 258 expansion_dir = os.path.join( 259 FSGSDirectories.get_base_dir(), "Workspace", "Expansion" 260 ) 261 if expansion_dir and os.path.isdir(expansion_dir): 262 result.append(expansion_dir) 263 264 if System.macos: 265 system_plugins_dir = os.path.normpath( 266 os.path.join( 267 fsboot.executable_dir(), 268 "..", 269 "..", 270 "..", 271 "..", 272 "..", 273 "..", 274 "Plugins", 275 ) 276 ) 277 result.append(system_plugins_dir) 278 else: 279 system_plugins_dir = os.path.normpath( 280 os.path.join( 281 fsboot.executable_dir(), "..", "..", "..", "Plugins" 282 ) 283 ) 284 result.append(system_plugins_dir) 285 # if os.path.isdir(system_plugins_dir): 286 # result.append(system_plugins_dir) 287 288 return result 289 290 def __init__(self): 291 self._provides = {} 292 self._plugins = [] 293 self._plugins_map = {} 294 self.load_plugins() 295 for plugin in self._plugins: 296 for key, value in plugin.provides.items(): 297 self._provides[key] = plugin 298 299 def plugin(self, name): 300 return self._plugins_map[name] 301 302 def plugins(self): 303 return self._plugins.copy() 304 305 def provides(self): 306 return self._provides.copy() 307 308 def load_plugins(self): 309 plugin_path = self.plugin_path() 310 logger.info("Executable dir: %s", fsboot.executable_dir()) 311 logger.info("Path: %s", plugin_path) 312 logger.info("Machine: %s", platform.machine().lower()) 313 logger.info("Architecture: %s", platform.architecture()[0]) 314 logger.info( 315 "Plugin OS/arch: %s/%s", 316 Plugin.os_name(True), 317 Plugin.arch_name(True), 318 ) 319 320 # Reversing order so that later loaded plugins (earlier on path) 321 # takes precedence. 322 # for dir_path in reversed(plugin_path): 323 # I guess we should sort the list by version number and only load the 324 # newest plugin per name. 325 for dir_path in plugin_path: 326 if not os.path.isdir(dir_path): 327 continue 328 # logger.debug(dir_path) 329 for name in os.listdir(dir_path): 330 plugin_dir = os.path.join(dir_path, name) 331 logger.debug(plugin_dir) 332 if not os.path.isdir(plugin_dir): 333 continue 334 try: 335 plugin = self.load_plugin(plugin_dir) 336 except Exception: 337 logger.debug("Could not load %s", plugin_dir) 338 traceback.print_exc() 339 continue 340 if plugin is None: 341 logger.debug("No plugin in %s", plugin_dir) 342 continue 343 logger.debug("Found plugin in %s", plugin_dir) 344 self._plugins.append(plugin) 345 self._plugins_map[plugin.name] = plugin 346 self._plugins.sort(key=attrgetter("name")) 347 348 def load_plugin(self, plugin_dir): 349 return self.load_expansion(plugin_dir) 350 351 # logger.info("Load plugin %s", plugin_dir) 352 # plugin_ini = os.path.join(plugin_dir, "plugin.ini") 353 # name = os.path.basename(plugin_dir).split("_")[0] 354 # if not os.path.exists(plugin_ini): 355 # return self.load_expansion(plugin_dir) 356 # cp = ConfigParser() 357 # with open(plugin_ini, "r", encoding="UTF-8") as f: 358 # cp.read_file(f) 359 # version = cp.get("plugin", "version") 360 # plugin = Plugin(os.path.join(plugin_dir, version), name, version, cp) 361 # return plugin 362 363 def load_expansion(self, path): 364 logger.info("Load expansion %s", path) 365 arch_path = os.path.join( 366 path, Plugin.os_name(pretty=True), Plugin.arch_name(pretty=True) 367 ) 368 version_path = os.path.join(arch_path, "Version.txt") 369 if not os.path.exists(version_path): 370 # version_path = os.path.join(path, "Data", "Version.txt") 371 version_path = os.path.join(path, "Version.txt") 372 373 version = None 374 if not os.path.exists(version_path): 375 plugin_ini = os.path.join(path, "Plugin.ini") 376 if os.path.exists(plugin_ini): 377 cp = ConfigParser() 378 with open(plugin_ini, "r", encoding="UTF-8") as f: 379 cp.read_file(f) 380 version = cp.get("plugin", "version") 381 if not version: 382 plugin_ini = os.path.join(path, "plugin.ini") 383 if os.path.exists(plugin_ini): 384 cp = ConfigParser() 385 with open(plugin_ini, "r", encoding="UTF-8") as f: 386 cp.read_file(f) 387 version = cp.get("plugin", "version") 388 if not version: 389 return None 390 391 expansion = Expansion(path, arch_path, version=version) 392 expansion.load_provides() 393 return expansion 394 395 def find_resource(self, name): 396 plugin = self._provides[name] 397 return PluginResource(plugin, name) 398 399 def find_executable(self, name): 400 logger.debug("PluginManager.find_executable %s", repr(name)) 401 try: 402 plugin = self.provides()["executable:" + name] 403 except KeyError: 404 # Did not find executable in plugin, try to find executable 405 # bundled with the program. 406 if windows: 407 exe_name = name + ".exe" 408 else: 409 exe_name = name 410 path = os.path.join(fsboot.executable_dir(), exe_name) 411 logger.debug("Checking %s", path) 412 if os.path.exists(path): 413 logger.debug("Found non-plugin executable %s", path) 414 return Executable(path) 415 if fsboot.development(): 416 if name == "x64sc-fs": 417 logger.debug("Lookup hack for vice-fs/x64sc-fs") 418 name = "vice-fs" 419 path = os.path.join( 420 fsboot.executable_dir(), "..", name, exe_name 421 ) 422 logger.debug("Checking %s", path) 423 if os.path.exists(path): 424 logger.debug("Found non-plugin executable %s", path) 425 return Executable(path) 426 return None 427 return plugin.executable(name) 428 429 def find_library_path(self, name): 430 logger.debug("PluginManager.find_library_path %s", repr(name)) 431 try: 432 plugin = self.provides()["library:" + name] 433 except KeyError: 434 # Did not find module in plugin, try to find module 435 # bundled with the program. 436 if windows: 437 module_name = name + ".dll" 438 else: 439 module_name = name + ".so" 440 path = os.path.join(fsboot.executable_dir(), module_name) 441 if os.path.exists(path): 442 logger.debug("Found non-plugin module %s", path) 443 return path 444 return None 445 return plugin.library_path(name) 446 447 def find_file_by_sha1(self, sha1): 448 try: 449 plugin = self.provides()["sha1:" + sha1] 450 except KeyError: 451 path = None 452 else: 453 path = plugin.sha1_path(sha1) 454 logger.debug("PluginManager.find_file_by_sha1 %s -> %s", sha1, path) 455 return path 456