mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00

* adds handling for the `--` cli arg by having launcher capture, ignore, and pass through all of the values after it, while only processing (and validating) the values before it updates text client and its components to allow for args to be passed through, captured in run_as_textclient, and used in parse_args if present * Update worlds/LauncherComponents.py Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com> * explicitly using default args for parse_args when launched directly * revert manual arg parsing by request * Update CommonClient.py * Update LauncherComponents.py * :) --------- Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com> Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
423 lines
15 KiB
Python
423 lines
15 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
|
|
|
|
import Utils
|
|
import settings
|
|
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
|
|
|
|
if __name__ == "__main__":
|
|
import ModuleUpdate
|
|
ModuleUpdate.update()
|
|
|
|
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
|
|
is_windows, is_macos, is_linux
|
|
|
|
|
|
def open_host_yaml():
|
|
file = settings.get_settings().filename
|
|
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("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()
|
|
|
|
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
|
|
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()
|