* fixes url launched popup so it can close cleanly after spawning another kivy app like text client * whoops
		
			
				
	
	
		
			430 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			430 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
"""
 | 
						|
Archipelago launcher for bundled app.
 | 
						|
 | 
						|
* if run with APBP as argument, launch corresponding client.
 | 
						|
* if run with executable as argument, run it passing argv[2:] as arguments
 | 
						|
* if run without arguments, open launcher GUI
 | 
						|
 | 
						|
Scroll down to components= to add components to the launcher as well as setup.py
 | 
						|
"""
 | 
						|
 | 
						|
 | 
						|
import argparse
 | 
						|
import itertools
 | 
						|
import logging
 | 
						|
import multiprocessing
 | 
						|
import shlex
 | 
						|
import subprocess
 | 
						|
import sys
 | 
						|
import urllib.parse
 | 
						|
import webbrowser
 | 
						|
from os.path import isfile
 | 
						|
from shutil import which
 | 
						|
from typing import Callable, Optional, Sequence, Tuple, Union
 | 
						|
 | 
						|
if __name__ == "__main__":
 | 
						|
    import ModuleUpdate
 | 
						|
    ModuleUpdate.update()
 | 
						|
 | 
						|
import settings
 | 
						|
import Utils
 | 
						|
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
 | 
						|
                   user_path)
 | 
						|
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
 | 
						|
 | 
						|
 | 
						|
def open_host_yaml():
 | 
						|
    s = settings.get_settings()
 | 
						|
    file = s.filename
 | 
						|
    s.save()
 | 
						|
    assert file, "host.yaml missing"
 | 
						|
    if is_linux:
 | 
						|
        exe = which('sensible-editor') or which('gedit') or \
 | 
						|
              which('xdg-open') or which('gnome-open') or which('kde-open')
 | 
						|
        subprocess.Popen([exe, file])
 | 
						|
    elif is_macos:
 | 
						|
        exe = which("open")
 | 
						|
        subprocess.Popen([exe, file])
 | 
						|
    else:
 | 
						|
        webbrowser.open(file)
 | 
						|
 | 
						|
 | 
						|
def open_patch():
 | 
						|
    suffixes = []
 | 
						|
    for c in components:
 | 
						|
        if c.type == Type.CLIENT and \
 | 
						|
                isinstance(c.file_identifier, SuffixIdentifier) and \
 | 
						|
                (c.script_name is None or isfile(get_exe(c)[-1])):
 | 
						|
            suffixes += c.file_identifier.suffixes
 | 
						|
    try:
 | 
						|
        filename = open_filename("Select patch", (("Patches", suffixes),))
 | 
						|
    except Exception as e:
 | 
						|
        messagebox("Error", str(e), error=True)
 | 
						|
    else:
 | 
						|
        file, component = identify(filename)
 | 
						|
        if file and component:
 | 
						|
            exe = get_exe(component)
 | 
						|
            if exe is None or not isfile(exe[-1]):
 | 
						|
                exe = get_exe("Launcher")
 | 
						|
 | 
						|
            launch([*exe, file], component.cli)
 | 
						|
 | 
						|
 | 
						|
def generate_yamls():
 | 
						|
    from Options import generate_yaml_templates
 | 
						|
 | 
						|
    target = Utils.user_path("Players", "Templates")
 | 
						|
    generate_yaml_templates(target, False)
 | 
						|
    open_folder(target)
 | 
						|
 | 
						|
 | 
						|
def browse_files():
 | 
						|
    open_folder(user_path())
 | 
						|
 | 
						|
 | 
						|
def open_folder(folder_path):
 | 
						|
    if is_linux:
 | 
						|
        exe = which('xdg-open') or which('gnome-open') or which('kde-open')
 | 
						|
        subprocess.Popen([exe, folder_path])
 | 
						|
    elif is_macos:
 | 
						|
        exe = which("open")
 | 
						|
        subprocess.Popen([exe, folder_path])
 | 
						|
    else:
 | 
						|
        webbrowser.open(folder_path)
 | 
						|
 | 
						|
 | 
						|
def update_settings():
 | 
						|
    from settings import get_settings
 | 
						|
    get_settings().save()
 | 
						|
 | 
						|
 | 
						|
components.extend([
 | 
						|
    # Functions
 | 
						|
    Component("Open host.yaml", func=open_host_yaml),
 | 
						|
    Component("Open Patch", func=open_patch),
 | 
						|
    Component("Generate Template Options", func=generate_yamls),
 | 
						|
    Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
 | 
						|
    Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
 | 
						|
    Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
 | 
						|
    Component("Browse Files", func=browse_files),
 | 
						|
])
 | 
						|
 | 
						|
 | 
						|
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
 | 
						|
    url = urllib.parse.urlparse(path)
 | 
						|
    queries = urllib.parse.parse_qs(url.query)
 | 
						|
    launch_args = (path, *launch_args)
 | 
						|
    client_component = None
 | 
						|
    text_client_component = None
 | 
						|
    if "game" in queries:
 | 
						|
        game = queries["game"][0]
 | 
						|
    else:  # TODO around 0.6.0 - this is for pre this change webhost uri's
 | 
						|
        game = "Archipelago"
 | 
						|
    for component in components:
 | 
						|
        if component.supports_uri and component.game_name == game:
 | 
						|
            client_component = component
 | 
						|
        elif component.display_name == "Text Client":
 | 
						|
            text_client_component = component
 | 
						|
 | 
						|
    from kvui import App, Button, BoxLayout, Label, Clock, Window
 | 
						|
 | 
						|
    class Popup(App):
 | 
						|
        timer_label: Label
 | 
						|
        remaining_time: Optional[int]
 | 
						|
 | 
						|
        def __init__(self):
 | 
						|
            self.title = "Connect to Multiworld"
 | 
						|
            self.icon = r"data/icon.png"
 | 
						|
            super().__init__()
 | 
						|
 | 
						|
        def build(self):
 | 
						|
            layout = BoxLayout(orientation="vertical")
 | 
						|
 | 
						|
            if client_component is None:
 | 
						|
                self.remaining_time = 7
 | 
						|
                label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
 | 
						|
                              f"Launching Text Client in 7 seconds...")
 | 
						|
                self.timer_label = Label(text=label_text)
 | 
						|
                layout.add_widget(self.timer_label)
 | 
						|
                Clock.schedule_interval(self.update_label, 1)
 | 
						|
            else:
 | 
						|
                layout.add_widget(Label(text="Select client to open and connect with."))
 | 
						|
                button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
 | 
						|
 | 
						|
                text_client_button = Button(
 | 
						|
                    text=text_client_component.display_name,
 | 
						|
                    on_release=lambda *args: run_component(text_client_component, *launch_args)
 | 
						|
                )
 | 
						|
                button_row.add_widget(text_client_button)
 | 
						|
 | 
						|
                game_client_button = Button(
 | 
						|
                    text=client_component.display_name,
 | 
						|
                    on_release=lambda *args: run_component(client_component, *launch_args)
 | 
						|
                )
 | 
						|
                button_row.add_widget(game_client_button)
 | 
						|
 | 
						|
                layout.add_widget(button_row)
 | 
						|
 | 
						|
            return layout
 | 
						|
 | 
						|
        def update_label(self, dt):
 | 
						|
            if self.remaining_time > 1:
 | 
						|
                # countdown the timer and string replace the number
 | 
						|
                self.remaining_time -= 1
 | 
						|
                self.timer_label.text = self.timer_label.text.replace(
 | 
						|
                    str(self.remaining_time + 1), str(self.remaining_time)
 | 
						|
                )
 | 
						|
            else:
 | 
						|
                # our timer is finished so launch text client and close down
 | 
						|
                run_component(text_client_component, *launch_args)
 | 
						|
                Clock.unschedule(self.update_label)
 | 
						|
                App.get_running_app().stop()
 | 
						|
                Window.close()
 | 
						|
 | 
						|
        def _stop(self, *largs):
 | 
						|
            # see run_gui Launcher _stop comment for details
 | 
						|
            self.root_window.close()
 | 
						|
            super()._stop(*largs)
 | 
						|
 | 
						|
    Popup().run()
 | 
						|
 | 
						|
 | 
						|
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
 | 
						|
    if path is None:
 | 
						|
        return None, None
 | 
						|
    for component in components:
 | 
						|
        if component.handles_file(path):
 | 
						|
            return path, component
 | 
						|
        elif path == component.display_name or path == component.script_name:
 | 
						|
            return None, component
 | 
						|
    return None, None
 | 
						|
 | 
						|
 | 
						|
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
 | 
						|
    if isinstance(component, str):
 | 
						|
        name = component
 | 
						|
        component = None
 | 
						|
        if name.startswith("Archipelago"):
 | 
						|
            name = name[11:]
 | 
						|
        if name.endswith(".exe"):
 | 
						|
            name = name[:-4]
 | 
						|
        if name.endswith(".py"):
 | 
						|
            name = name[:-3]
 | 
						|
        if not name:
 | 
						|
            return None
 | 
						|
        for c in components:
 | 
						|
            if c.script_name == name or c.frozen_name == f"Archipelago{name}":
 | 
						|
                component = c
 | 
						|
                break
 | 
						|
        if not component:
 | 
						|
            return None
 | 
						|
    if is_frozen():
 | 
						|
        suffix = ".exe" if is_windows else ""
 | 
						|
        return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None
 | 
						|
    else:
 | 
						|
        return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None
 | 
						|
 | 
						|
 | 
						|
def launch(exe, in_terminal=False):
 | 
						|
    if in_terminal:
 | 
						|
        if is_windows:
 | 
						|
            subprocess.Popen(['start', *exe], shell=True)
 | 
						|
            return
 | 
						|
        elif is_linux:
 | 
						|
            terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
 | 
						|
            if terminal:
 | 
						|
                subprocess.Popen([terminal, '-e', shlex.join(exe)])
 | 
						|
                return
 | 
						|
        elif is_macos:
 | 
						|
            terminal = [which('open'), '-W', '-a', 'Terminal.app']
 | 
						|
            subprocess.Popen([*terminal, *exe])
 | 
						|
            return
 | 
						|
    subprocess.Popen(exe)
 | 
						|
 | 
						|
 | 
						|
refresh_components: Optional[Callable[[], None]] = None
 | 
						|
 | 
						|
 | 
						|
def run_gui():
 | 
						|
    from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
 | 
						|
    from kivy.core.window import Window
 | 
						|
    from kivy.uix.image import AsyncImage
 | 
						|
    from kivy.uix.relativelayout import RelativeLayout
 | 
						|
 | 
						|
    class Launcher(App):
 | 
						|
        base_title: str = "Archipelago Launcher"
 | 
						|
        container: ContainerLayout
 | 
						|
        grid: GridLayout
 | 
						|
        _tool_layout: Optional[ScrollBox] = None
 | 
						|
        _client_layout: Optional[ScrollBox] = None
 | 
						|
 | 
						|
        def __init__(self, ctx=None):
 | 
						|
            self.title = self.base_title + " " + Utils.__version__
 | 
						|
            self.ctx = ctx
 | 
						|
            self.icon = r"data/icon.png"
 | 
						|
            super().__init__()
 | 
						|
 | 
						|
        def _refresh_components(self) -> None:
 | 
						|
 | 
						|
            def build_button(component: Component) -> Widget:
 | 
						|
                """
 | 
						|
                Builds a button widget for a given component.
 | 
						|
 | 
						|
                Args:
 | 
						|
                    component (Component): The component associated with the button.
 | 
						|
 | 
						|
                Returns:
 | 
						|
                    None. The button is added to the parent grid layout.
 | 
						|
 | 
						|
                """
 | 
						|
                button = Button(text=component.display_name, size_hint_y=None, height=40)
 | 
						|
                button.component = component
 | 
						|
                button.bind(on_release=self.component_action)
 | 
						|
                if component.icon != "icon":
 | 
						|
                    image = AsyncImage(source=icon_paths[component.icon],
 | 
						|
                                       size=(38, 38), size_hint=(None, 1), pos=(5, 0))
 | 
						|
                    box_layout = RelativeLayout(size_hint_y=None, height=40)
 | 
						|
                    box_layout.add_widget(button)
 | 
						|
                    box_layout.add_widget(image)
 | 
						|
                    return box_layout
 | 
						|
                return button
 | 
						|
 | 
						|
            # clear before repopulating
 | 
						|
            assert self._tool_layout and self._client_layout, "must call `build` first"
 | 
						|
            tool_children = reversed(self._tool_layout.layout.children)
 | 
						|
            for child in tool_children:
 | 
						|
                self._tool_layout.layout.remove_widget(child)
 | 
						|
            client_children = reversed(self._client_layout.layout.children)
 | 
						|
            for child in client_children:
 | 
						|
                self._client_layout.layout.remove_widget(child)
 | 
						|
 | 
						|
            _tools = {c.display_name: c for c in components if c.type == Type.TOOL}
 | 
						|
            _clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
 | 
						|
            _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
 | 
						|
            _miscs = {c.display_name: c for c in components if c.type == Type.MISC}
 | 
						|
 | 
						|
            for (tool, client) in itertools.zip_longest(itertools.chain(
 | 
						|
                _tools.items(), _miscs.items(), _adjusters.items()
 | 
						|
            ), _clients.items()):
 | 
						|
                # column 1
 | 
						|
                if tool:
 | 
						|
                    self._tool_layout.layout.add_widget(build_button(tool[1]))
 | 
						|
                # column 2
 | 
						|
                if client:
 | 
						|
                    self._client_layout.layout.add_widget(build_button(client[1]))
 | 
						|
 | 
						|
        def build(self):
 | 
						|
            self.container = ContainerLayout()
 | 
						|
            self.grid = GridLayout(cols=2)
 | 
						|
            self.container.add_widget(self.grid)
 | 
						|
            self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
 | 
						|
            self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
 | 
						|
            self._tool_layout = ScrollBox()
 | 
						|
            self._tool_layout.layout.orientation = "vertical"
 | 
						|
            self.grid.add_widget(self._tool_layout)
 | 
						|
            self._client_layout = ScrollBox()
 | 
						|
            self._client_layout.layout.orientation = "vertical"
 | 
						|
            self.grid.add_widget(self._client_layout)
 | 
						|
 | 
						|
            self._refresh_components()
 | 
						|
 | 
						|
            global refresh_components
 | 
						|
            refresh_components = self._refresh_components
 | 
						|
 | 
						|
            Window.bind(on_drop_file=self._on_drop_file)
 | 
						|
 | 
						|
            return self.container
 | 
						|
 | 
						|
        @staticmethod
 | 
						|
        def component_action(button):
 | 
						|
            if button.component.func:
 | 
						|
                button.component.func()
 | 
						|
            else:
 | 
						|
                launch(get_exe(button.component), button.component.cli)
 | 
						|
 | 
						|
        def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
 | 
						|
            """ When a patch file is dropped into the window, run the associated component. """
 | 
						|
            file, component = identify(filename.decode())
 | 
						|
            if file and component:
 | 
						|
                run_component(component, file)
 | 
						|
            else:
 | 
						|
                logging.warning(f"unable to identify component for {file}")
 | 
						|
 | 
						|
        def _stop(self, *largs):
 | 
						|
            # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
 | 
						|
            # Closing the window explicitly cleans it up.
 | 
						|
            self.root_window.close()
 | 
						|
            super()._stop(*largs)
 | 
						|
 | 
						|
    Launcher().run()
 | 
						|
 | 
						|
    # avoiding Launcher reference leak
 | 
						|
    # and don't try to do something with widgets after window closed
 | 
						|
    global refresh_components
 | 
						|
    refresh_components = None
 | 
						|
 | 
						|
 | 
						|
def run_component(component: Component, *args):
 | 
						|
    if component.func:
 | 
						|
        component.func(*args)
 | 
						|
        if refresh_components:
 | 
						|
            refresh_components()
 | 
						|
    elif component.script_name:
 | 
						|
        subprocess.run([*get_exe(component.script_name), *args])
 | 
						|
    else:
 | 
						|
        logging.warning(f"Component {component} does not appear to be executable.")
 | 
						|
 | 
						|
 | 
						|
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
 | 
						|
    if isinstance(args, argparse.Namespace):
 | 
						|
        args = {k: v for k, v in args._get_kwargs()}
 | 
						|
    elif not args:
 | 
						|
        args = {}
 | 
						|
 | 
						|
    path = args.get("Patch|Game|Component|url", None)
 | 
						|
    if path is not None:
 | 
						|
        if path.startswith("archipelago://"):
 | 
						|
            handle_uri(path, args.get("args", ()))
 | 
						|
            return
 | 
						|
        file, component = identify(path)
 | 
						|
        if file:
 | 
						|
            args['file'] = file
 | 
						|
        if component:
 | 
						|
            args['component'] = component
 | 
						|
        if not component:
 | 
						|
            logging.warning(f"Could not identify Component responsible for {path}")
 | 
						|
 | 
						|
    if args["update_settings"]:
 | 
						|
        update_settings()
 | 
						|
    if "file" in args:
 | 
						|
        run_component(args["component"], args["file"], *args["args"])
 | 
						|
    elif "component" in args:
 | 
						|
        run_component(args["component"], *args["args"])
 | 
						|
    elif not args["update_settings"]:
 | 
						|
        run_gui()
 | 
						|
 | 
						|
 | 
						|
if __name__ == '__main__':
 | 
						|
    init_logging('Launcher')
 | 
						|
    Utils.freeze_support()
 | 
						|
    multiprocessing.set_start_method("spawn")  # if launched process uses kivy, fork won't work
 | 
						|
    parser = argparse.ArgumentParser(
 | 
						|
        description='Archipelago Launcher',
 | 
						|
        usage="[-h] [--update_settings] [Patch|Game|Component] [-- component args here]"
 | 
						|
    )
 | 
						|
    run_group = parser.add_argument_group("Run")
 | 
						|
    run_group.add_argument("--update_settings", action="store_true",
 | 
						|
                           help="Update host.yaml and exit.")
 | 
						|
    run_group.add_argument("Patch|Game|Component|url", type=str, nargs="?",
 | 
						|
                           help="Pass either a patch file, a generated game, the component name to run, or a url to "
 | 
						|
                                "connect with.")
 | 
						|
    run_group.add_argument("args", nargs="*",
 | 
						|
                           help="Arguments to pass to component.")
 | 
						|
    main(parser.parse_args())
 | 
						|
 | 
						|
    from worlds.LauncherComponents import processes
 | 
						|
    for process in processes:
 | 
						|
        # we await all child processes to close before we tear down the process host
 | 
						|
        # this makes it feel like each one is its own program, as the Launcher is closed now
 | 
						|
        process.join()
 |