diff --git a/MultiWorld.py b/MultiWorld.py index 31b744c45..99cc06a73 100644 --- a/MultiWorld.py +++ b/MultiWorld.py @@ -118,41 +118,132 @@ def _ensure_writable_kivy_data(src: str) -> str: os.makedirs(os.environ["KIVY_HOME"], exist_ok=True) -def run_client(*args, queue=None): +def make_arg_parser() -> ArgumentParser: + """Build the MultiWorldGG argument parser. + + The optional positional is what OS file associations and the + archipelago://-style URL protocols hand us: every registered suffix and + protocol points at `MultiWorldGG.exe "%1"`.""" + parser = ArgumentParser() + parser.add_argument("launch_file", type=str, default=None, nargs="?", metavar="PATCH_FILE|URL", + help="A patch file to route to its game's client (OS file-association double-click), " + "a file handled by a tool component (e.g. a .archipelago multidata for the Host), " + "or an archipelago:// launch URL") + parser.add_argument("--game", type=str, default=None, required=False, help="The game module to launch\nGame Name will not work, use the apworld abbreviation") + parser.add_argument("--server-address", type=str, default=None, required=False, help="The server address to connect to") + parser.add_argument("--slot-name", type=str, default=None, required=False, help="The slot name to connect to") + parser.add_argument("--password", type=str, default=None, required=False, help="The password to connect to") + parser.add_argument("--update-modules", action="store_true", default=False, required=False, help="Whether to update modules") + parser.add_argument("--worlds", nargs="+", default=None, required=False, help="List of worlds to update") + parser.add_argument("--loglevel", default="debug", + choices=['debug', 'info', 'warning', 'error', 'critical'], + help="Set the logging level") + parser.add_argument("--frontend", default="gui", choices=["gui", "tui"], + help="Which frontend to launch: 'gui' (Kivy desktop, default) or 'tui' (Textual terminal)") + # Internal: set by Utils._restart_client_with_args() so a second launch + # failure surfaces an error dialog instead of looping forever. + parser.add_argument("--no-restart", action="store_true", default=False, + help=argparse.SUPPRESS) + return parser + + +async def _route_module_when_ui_ready(module_name: str, timeout: float = 30.0, **launch_kwargs) -> None: + """Launch a world module's client once the launcher frontend is up. + + Beta clients cannot run standalone — they take over the launcher UI — so + this mirrors the GUI's own launch flow (mwgg_gui launcher._launch_module): + wait for the frontend, then route through Utils.discover_and_launch_module, + which installs the world on demand and forwards `launch_kwargs` (e.g. + patch_file from a double-clicked patch, or server_address from a CLI + --game launch) to the resolved client. The screen-flip hooks are + feature-detected so the TUI (or an older GUI) simply skips them.""" + from frontend_protocol import resolve_frontend_class + + logger = logging.getLogger("MultiWorld") + loop = asyncio.get_event_loop() + deadline = loop.time() + timeout + app = None + while loop.time() < deadline: + app = getattr(resolve_frontend_class(), "_active_instance", None) + if app is not None and (getattr(app, "root", None) is not None # Kivy: root built + or getattr(app, "is_running", False)): # Textual: running + break + await asyncio.sleep(0.2) + if app is None: + logger.error(f"Frontend did not come up; cannot launch module {module_name}") + return + + pre_hook = getattr(app, "client_console_init", None) + if callable(pre_hook): + try: + pre_hook() + except Exception: + logger.exception("client_console_init failed; continuing module launch") + + def ready_callback(*_cb_args): + try: + console_init = getattr(app, "console_init", None) + change_screen = getattr(app, "change_screen", None) + if callable(console_init): + console_init() + if callable(change_screen): + change_screen("console") + except Exception: + logger.exception("Could not switch to the console screen after module launch") + + def error_callback(*_cb_args): + logger.error(f"Failed to launch a client for module {module_name}") + + from Utils import discover_and_launch_module + try: + discover_and_launch_module(module_name=module_name, + ready_callback=ready_callback, + error_callback=error_callback, + **launch_kwargs) + except Exception: + logger.exception(f"Module launch failed for {module_name}") + + +def run_client(args=None, queue=None): """Start the MWGG client""" - async def main(args: list[str]): + async def main(args): from CommonClient import InitContext logger = logging.getLogger("MultiWorld") ctx = InitContext() - - # Check if a specific module was requested + + # Resolve a requested module launch (CLI --game, or a routed patch + # file). Beta clients take over the launcher UI rather than running + # standalone, so the launch itself is deferred until the frontend is up. + route_module = None + route_kwargs = {} try: if args and args.game and args.server_address: logger.info(f"Attempting to launch game: {args.game}") - from Utils import get_available_worlds, discover_and_launch_module + from Utils import get_available_worlds - if args.game not in get_available_worlds(): - raise Exception(f"Game {args.game} not found in available worlds") + if args.game in get_available_worlds(): + route_module = args.game + route_kwargs = {"server_address": args.server_address, + "_restarted": getattr(args, "no_restart", False)} + else: + logger.error(f"Game {args.game} not found in available worlds; falling back to launcher") + elif args and getattr(args, "patch_module", None): + route_module = args.patch_module + route_kwargs = {"patch_file": args.patch_file} + except Exception: + logger.exception("Could not resolve requested module launch; falling back to launcher") - # Try to launch the module via entrypoints - try: - discover_and_launch_module(module_name=args.game, - server_address=args.server_address, - _restarted=getattr(args, "no_restart", False)) - return # Module takeover successful, exit initial client - except Exception as e: - logger.error(f"Module launch failed: {e}") - # Fall back to initial client - logger.info("Falling back to launcher") - except Exception as e: - pass - # Default initial client behavior logger.info("Launching default GUI") try: ctx.run_gui(splash_queue=queue) + if route_module: + # Keep a reference so the routing task isn't garbage-collected. + routing_task = asyncio.create_task( # noqa: F841 + _route_module_when_ui_ready(route_module, **route_kwargs), + name="ModuleRouting") await ctx.exit_event.wait() except Exception as e: logger.error(f"Error during GUI execution: {e}", exc_info=True) @@ -191,22 +282,7 @@ async def main(args: list[str]): freeze_support() # Parse the command line arguments - parser = ArgumentParser() - parser.add_argument("--game", type=str, default=None, required=False, help="The game module to launch\nGame Name will not work, use the apworld abbreviation") - parser.add_argument("--server-address", type=str, default=None, required=False, help="The server address to connect to") - parser.add_argument("--slot-name", type=str, default=None, required=False, help="The slot name to connect to") - parser.add_argument("--password", type=str, default=None, required=False, help="The password to connect to") - parser.add_argument("--update-modules", action="store_true", default=False, required=False, help="Whether to update modules") - parser.add_argument("--worlds", nargs="+", default=None, required=False, help="List of worlds to update") - parser.add_argument("--loglevel", default="debug", - choices=['debug', 'info', 'warning', 'error', 'critical'], - help="Set the logging level") - parser.add_argument("--frontend", default="gui", choices=["gui", "tui"], - help="Which frontend to launch: 'gui' (Kivy desktop, default) or 'tui' (Textual terminal)") - # Internal: set by Utils._restart_client_with_args() so a second launch - # failure surfaces an error dialog instead of looping forever. - parser.add_argument("--no-restart", action="store_true", default=False, - help=argparse.SUPPRESS) + parser = make_arg_parser() if sys.argv[1:]: args = parser.parse_args(sys.argv[1:]) @@ -287,5 +363,49 @@ async def main(args: list[str]): except Exception as e: logger.warning(f"Could not scan custom_worlds on launch: {e}", exc_info=True) + # Route a double-clicked / positional file before the GUI comes up. Patch + # containers carry their game name in their root archipelago.json (same + # pre-load pattern as Patch.py), which resolves to a world module without + # importing any worlds; the client launch itself is deferred until the + # launcher UI is running (beta clients take over the launcher UI). Files + # claimed by a builtin tool component (.archipelago/.mwgg/.zip multidata -> + # Host) launch that component directly instead. + args.patch_module = None + args.patch_file = None + if args.launch_file: + if args.launch_file.startswith(("archipelago://", "mwgg://", "multiworldgg://")): + # Launch URLs open the launcher GUI; connection details are entered there. + logger.info(f"Launched with URL {args.launch_file}; opening launcher.") + elif os.path.isfile(args.launch_file): + from Utils import read_patch_game_name + game_name = read_patch_game_name(args.launch_file) + if game_name: + from mwgg_igdb import GameIndex + module_name = GameIndex.get_module_for_game(game_name) + if module_name: + args.patch_module = module_name + args.patch_file = os.path.abspath(args.launch_file) + logger.info(f"Routing patch file {args.launch_file} to {game_name} ({module_name})") + else: + logger.warning(f"No installed or indexed world handles game {game_name!r} " + f"from {args.launch_file}; opening launcher.") + else: + from worlds.LauncherComponents import identify, get_exe, launch_exe + component = identify(args.launch_file) + if component is not None: + logger.info(f"Routing {args.launch_file} to component {component.display_name}") + if component.func: + component.func(args.launch_file) + sys.exit(0) + exe = get_exe(component) + if exe: + launch_exe([*exe, args.launch_file], component.cli) + sys.exit(0) + logger.warning(f"Component {component.display_name} is not executable; opening launcher.") + else: + logger.warning(f"Could not identify a handler for {args.launch_file}; opening launcher.") + else: + logger.warning(f"File not found: {args.launch_file}; opening launcher.") + # Run the main client in the current process run_client(args, queue=splash_queue) \ No newline at end of file diff --git a/Utils.py b/Utils.py index 529abec4d..f88779344 100644 --- a/Utils.py +++ b/Utils.py @@ -352,6 +352,22 @@ def discover_custom_world_module(custom_world: Path) -> Optional[str]: return None +def read_patch_game_name(patch_path: str) -> Optional[str]: + """Read the game name from a patch container's root archipelago.json. + + Every APContainer-derived patch format (.aplttp, .apkh3, .apemerald, ...) + is a zip with the manifest at the archive root. Returns None for anything + else (multidata, apworlds, stray files) so callers can fall back to + component-suffix routing.""" + try: + with zipfile.ZipFile(patch_path, "r") as zf: + manifest = json.loads(zf.read("archipelago.json").decode("utf-8")) + except (OSError, zipfile.BadZipFile, KeyError, ValueError): + return None + game = manifest.get("game") if isinstance(manifest, dict) else None + return game if isinstance(game, str) and game else None + + def _resolve_launch_from_custom_world(wrapper_func: callable, module_id: str) -> Optional[callable]: """ Returns the inner callable for custom worlds, or None if the wrapper doesn't match the @@ -407,6 +423,10 @@ def discover_and_launch_module(module_name: str, **kwargs) -> Optional[callable] Frontend-neutral: worker-thread callbacks marshal back to the asyncio loop via loop.call_soon_threadsafe, which works for both the Kivy GUI and the Textual TUI (both run inside the same asyncio loop driven by MultiWorld.py). + + A `patch_file` kwarg is forwarded to the resolved client: as a positional + CLI arg for entry-point/component launch functions, as `diff_file=` for the + SNI client, and as the patch positional for the BizHawk fallback. """ import threading import asyncio @@ -493,23 +513,35 @@ def _fire_pending_error_callback() -> None: logging.error(f"Error in error callback: {cb_err}") +def _client_launch_argv(server_address, slot_name: typing.Optional[str], label: str, + patch_file: typing.Optional[str] = None) -> typing.List[str]: + """Translate launcher-provided kwargs into the CLI argv world clients parse: + an optional positional patch file followed by --connect/--name flags.""" + launch_argv: typing.List[str] = [] + if patch_file: + launch_argv.append(patch_file) + if isinstance(server_address, str) and server_address: + launch_argv.append(f"--connect={server_address}") + if slot_name and label == "universal_tracker": + launch_argv.append(f"--name={slot_name}") + return launch_argv + + def _defer_cli_launch(launch_function, label: str, server_address, already_restarted: bool, dep_install_module: typing.Optional[str] = None, - slot_name: typing.Optional[str] = None) -> None: + slot_name: typing.Optional[str] = None, + patch_file: typing.Optional[str] = None) -> None: """Defer a CLI-style world launch() to the next asyncio iteration with - sys.argv translated from the launcher-provided server_address.""" + sys.argv translated from the launcher-provided server_address/patch_file.""" loop = asyncio.get_event_loop() def _deferred_launch(): import inspect saved_argv = sys.argv[:] try: - launch_argv: list[str] = [] - if isinstance(server_address, str) and server_address: - launch_argv.append(f"--connect={server_address}") - if slot_name and label == "universal_tracker": - launch_argv.append(f"--name={slot_name}") + launch_argv = _client_launch_argv(server_address, slot_name, label, patch_file) + if launch_argv: sys.argv = [sys.argv[0], *launch_argv] try: accepts_positional = any( @@ -575,6 +607,7 @@ def _perform_module_launch(module_id: str, **kwargs): client_type = kwargs.pop("client_type", "text") server_address = kwargs.pop("server_address", None) slot_name = kwargs.pop("slot_name", None) + patch_file = kwargs.pop("patch_file", None) already_restarted = kwargs.pop("_restarted", False) CommonClient._set_pending_launch_callbacks(ready_callback, error_callback) @@ -644,6 +677,7 @@ def _perform_module_launch(module_id: str, **kwargs): launch_function, module_id, server_address, already_restarted, dep_install_module=module_id, slot_name=slot_name, + patch_file=patch_file, ) return None @@ -655,7 +689,7 @@ def _perform_module_launch(module_id: str, **kwargs): if AutoSNIClientRegister.is_sni_world(module_name=game_name): logging.info(f"Detected SNI client for {game_name}") from worlds._sni.context import launch as _sni_launch - return _sni_launch(server_address=server_address) + return _sni_launch(server_address=server_address, diff_file=patch_file) except ImportError: logging.debug("SNI client not available") @@ -665,7 +699,8 @@ def _perform_module_launch(module_id: str, **kwargs): if AutoBizHawkClientRegister.is_bizhawk_world(module_name=game_name): logging.info(f"Detected BizHawk client for {game_name}") from worlds._bizhawk.context import launch as _bizhawk_launch - _defer_cli_launch(_bizhawk_launch, "bizhawk", server_address, already_restarted) + _defer_cli_launch(_bizhawk_launch, "bizhawk", server_address, already_restarted, + patch_file=patch_file) return None except ImportError: logging.debug("BizHawk client not available") @@ -680,6 +715,8 @@ def _perform_module_launch(module_id: str, **kwargs): return None # Fallback to text client + if patch_file: + logging.warning(f"No specialized client claims patch file {patch_file}; it will be ignored.") logging.info(f"No specialized client, using text client") from CommonClient import main_textclient result = main_textclient(server_address) diff --git a/test/general/test_patch_routing.py b/test/general/test_patch_routing.py new file mode 100644 index 000000000..99f67e398 --- /dev/null +++ b/test/general/test_patch_routing.py @@ -0,0 +1,147 @@ +"""Tests for patch-file routing: opening a patch file (OS file-association +double-click or CLI positional) must launch the right game client with it. + +Guards the pieces the flow is built from: + * MultiWorld's argument parser accepts an optional positional file/URL -- + every installer file association and URL protocol points at + `MultiWorldGG.exe "%1"`, which used to be an unrecognized argument + (SystemExit 2) for every game; + * Utils.read_patch_game_name reads the game name from a patch container's + root archipelago.json without importing any worlds (Patch.py pattern); + * Utils._client_launch_argv translates launcher kwargs into the CLI argv + world clients parse (positional patch file first, then --connect/--name); + * LauncherComponents.identify/get_exe route non-patch files (e.g. a + .archipelago multidata) to their tool component without the + `from Launcher import ...` paths that don't exist in beta. +""" +import json +import sys +import zipfile + +import MultiWorld +import Utils +from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, get_exe, identify + + +def _make_patch_container(path, game_name: str) -> None: + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("archipelago.json", json.dumps({"game": game_name, "compatible_version": 5})) + + +# --- MultiWorld argument parser --- + +def test_parser_accepts_positional_patch_path(): + args = MultiWorld.make_arg_parser().parse_args(["C:\\seeds\\AP_1234_P1.aplttp"]) + assert args.launch_file == "C:\\seeds\\AP_1234_P1.aplttp" + assert args.game is None + + +def test_parser_no_args_defaults(): + args = MultiWorld.make_arg_parser().parse_args([]) + assert args.launch_file is None + assert args.frontend == "gui" + + +def test_parser_positional_combines_with_flags(): + args = MultiWorld.make_arg_parser().parse_args(["seed.apkh3", "--frontend", "tui", "--loglevel", "info"]) + assert args.launch_file == "seed.apkh3" + assert args.frontend == "tui" + assert args.loglevel == "info" + + +def test_parser_accepts_launch_url(): + args = MultiWorld.make_arg_parser().parse_args(["archipelago://player:pass@multiworld.gg:38281"]) + assert args.launch_file == "archipelago://player:pass@multiworld.gg:38281" + + +# --- Utils.read_patch_game_name --- + +def test_read_patch_game_name_reads_root_manifest(tmp_path): + patch = tmp_path / "seed.apemerald" + _make_patch_container(patch, "Pokemon Emerald") + assert Utils.read_patch_game_name(str(patch)) == "Pokemon Emerald" + + +def test_read_patch_game_name_rejects_non_zip(tmp_path): + multidata = tmp_path / "AP_1234.archipelago" + multidata.write_bytes(b"\x78\x9c not a zip") + assert Utils.read_patch_game_name(str(multidata)) is None + + +def test_read_patch_game_name_rejects_zip_without_root_manifest(tmp_path): + apworld = tmp_path / "some.apworld" + with zipfile.ZipFile(apworld, "w") as zf: + zf.writestr("some/archipelago.json", json.dumps({"game": "Nested"})) + assert Utils.read_patch_game_name(str(apworld)) is None + + +def test_read_patch_game_name_missing_file(tmp_path): + assert Utils.read_patch_game_name(str(tmp_path / "missing.aplttp")) is None + + +def test_read_patch_game_name_manifest_without_game(tmp_path): + patch = tmp_path / "broken.aplttp" + with zipfile.ZipFile(patch, "w") as zf: + zf.writestr("archipelago.json", json.dumps({"compatible_version": 5})) + assert Utils.read_patch_game_name(str(patch)) is None + + +# --- Utils._client_launch_argv --- + +def test_client_launch_argv_patch_file_is_positional_before_connect(): + argv = Utils._client_launch_argv("localhost:38281", None, "kh3", patch_file="C:/seed.apkh3") + assert argv == ["C:/seed.apkh3", "--connect=localhost:38281"] + + +def test_client_launch_argv_patch_file_only(): + assert Utils._client_launch_argv(None, None, "bizhawk", patch_file="seed.apemerald") == ["seed.apemerald"] + + +def test_client_launch_argv_empty(): + assert Utils._client_launch_argv(None, None, "kh3") == [] + + +def test_client_launch_argv_tracker_gets_slot_name(): + argv = Utils._client_launch_argv("host:1", "Player1", "universal_tracker") + assert argv == ["--connect=host:1", "--name=Player1"] + + +def test_client_launch_argv_slot_name_ignored_for_regular_clients(): + assert Utils._client_launch_argv("host:1", "Player1", "alttp") == ["--connect=host:1"] + + +# --- LauncherComponents.identify / get_exe --- + +def test_identify_routes_multidata_to_host(): + component = identify("AP_1234.archipelago") + assert component is not None + assert component.display_name == "Host" + + +def test_identify_unknown_suffix_returns_none(): + assert identify("photo.png") is None + assert identify(None) is None + assert identify("") is None + + +def test_identify_world_registered_suffix(): + component = Component("Patch Routing Test Client", func=lambda *args: None, + component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".aproutingtest")) + components.append(component) + try: + assert identify("seed.aproutingtest") is component + finally: + components.remove(component) + + +def test_get_exe_resolves_script_component_in_dev(): + host = next(c for c in components if c.display_name == "Host") + exe = get_exe(host) + assert exe is not None + assert exe[0] == sys.executable + assert exe[1].endswith("MultiServer.py") + + +def test_get_exe_func_only_component_has_no_exe(): + component = Component("Func Only", func=lambda *args: None) + assert get_exe(component) is None diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 2421b0af3..215880416 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -14,7 +14,8 @@ from enum import Enum, auto from typing import Any, Optional, Callable, Iterable, Tuple -from Utils import local_path, open_filename, is_frozen, is_kivy_running, open_file, user_path, read_apignore +from Utils import local_path, open_filename, is_frozen, is_kivy_running, is_windows, open_file, user_path, \ + read_apignore try: from Utils import instance_name as apname @@ -192,6 +193,61 @@ def __call__(self, path: str) -> bool: return False +def identify(path: Optional[str]) -> Optional[Component]: + """Return the first registered component whose file_identifier claims `path`. + + Works against whatever is currently registered: with worlds unloaded that is + the builtin components plus any launcher-cache stubs (their suffixes are + serialized, so suffix lookups need no world import).""" + if not path: + return None + for component in components: + if component.handles_file(path): + return component + return None + + +def get_exe(component: Component) -> Optional[list[str]]: + """Resolve the command line that runs a script/frozen-name component. + + Beta equivalent of upstream Launcher.get_exe: the monorepo has no Launcher + module, so script components resolve against the frozen bundle root or the + repo checkout directly.""" + if is_frozen(): + suffix = ".exe" if is_windows else "" + return [local_path(f"{component.frozen_name}{suffix}")] if component.frozen_name else None + return [sys.executable, local_path(f"{component.script_name}.py")] if component.script_name else None + + +def launch_exe(exe: Iterable[str], in_terminal: bool = False) -> bool: + """Run the command line `exe` in a new process. With `in_terminal`, try to + run it in a terminal window; the return value reports whether one was used. + Beta equivalent of upstream Launcher.launch (which the monorepo lacks).""" + exe = list(exe) + if in_terminal: + if is_windows: + # intentionally using a window title with a space so it gets quoted and treated as a title + subprocess.Popen(["start", f"Running {apname}", *exe], shell=True) + return True + elif sys.platform.startswith("linux"): + from shutil import which + xdg = which("xdg-terminal-exec") + if xdg: + subprocess.Popen([xdg, "--", *exe]) + return True + terminal = which("x-terminal-emulator") or which("konsole") or which("gnome-terminal") or which("xterm") + if terminal: + import shlex + subprocess.Popen([terminal, "-e", shlex.join(exe)]) + return True + elif sys.platform == "darwin": + from shutil import which + subprocess.Popen([which("open"), "-W", "-a", "Terminal.app", *exe]) + return True + subprocess.Popen(exe) + return False + + def launch_textclient(*args): import CommonClient launch(CommonClient.run_as_textclient, name="TextClient", args=args) @@ -474,13 +530,11 @@ def _launch_component(component: Component, launch_args: tuple[str, ...]) -> Non return if component.script_name: - from Launcher import get_exe, launch - exe = get_exe(component) if not exe: logging.warning(f"Unable to resolve executable for launcher component {component.display_name}.") return - launch([*exe, *launch_args], component.cli) + launch_exe([*exe, *launch_args], component.cli) return logging.warning(f"Component {component.display_name} does not appear to be executable.") @@ -508,8 +562,6 @@ def _launch_cached_script_stub(component: Component, launch_args: tuple[str, ... if not (component.script_name or component.frozen_name): return False, None - from Launcher import get_exe, launch - exe = get_exe(component) if not exe: return False, None @@ -522,7 +574,7 @@ def _launch_cached_script_stub(component: Component, launch_args: tuple[str, ... return False, None if component.cli: - launch([*exe, *launch_args], component.cli) + launch_exe([*exe, *launch_args], component.cli) return True, None try: return True, subprocess.Popen([*exe, *launch_args]) @@ -533,12 +585,12 @@ def _launch_cached_script_stub(component: Component, launch_args: tuple[str, ... def _launch_cached_callable_stub(callable_module: str | None, callable_qualname: str | None, launch_args: tuple[str, ...]) -> tuple[bool, subprocess.Popen[Any] | None]: - if not callable_module or not callable_qualname: - return False, None - - from Launcher import launch_component_callable - launched_process = launch_component_callable(callable_module, callable_qualname, launch_args) - return launched_process is not None, launched_process + # Upstream relaunches cached callables through a separate Launcher process + # (`Launcher.launch_component_callable`). Beta has no Launcher executable and + # runs clients in-process, so report "not launched" and let + # _run_cached_component fall through to the script stub or the fully loaded + # component instead. + return False, None def _run_cached_component(component_id: tuple[Any, ...], callable_module: str | None,