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