Skip to content
38 changes: 38 additions & 0 deletions CommonClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,44 @@

import websockets
from websockets.protocol import State
from websockets.asyncio.connection import Connection

# World clients written against websockets 13.x (shipped MWGG) check liveness via
# the legacy `socket.closed` / `socket.open` attributes, which the websockets 14+
# asyncio Connection removed. Restore them as State-backed properties with the
# legacy semantics (both are False while opening/closing) so those clients run
# unmodified against the bundled websockets 16. Each offending call site gets one
# deprecation warning in the client log (not one per access -- these checks sit
# in per-package and watcher loops) so world authors can find and migrate it.
_legacy_socket_attr_sites: typing.Set[typing.Tuple[str, int, str]] = set()


def _warn_legacy_socket_attr(name: str) -> None:
frame = sys._getframe(2)
site = (frame.f_code.co_filename, frame.f_lineno, name)
if site in _legacy_socket_attr_sites:
return
_legacy_socket_attr_sites.add(site)
logging.getLogger("Client").warning(
"Deprecated websockets API: %s:%s reads socket.%s, which websockets 14+ removed. "
"MWGG shims it for now; migrate to socket.state checks against websockets.protocol.State.",
site[0], site[1], name)


def _compat_socket_closed(self: Connection) -> bool:
_warn_legacy_socket_attr("closed")
return self.state is State.CLOSED


def _compat_socket_open(self: Connection) -> bool:
_warn_legacy_socket_attr("open")
return self.state is State.OPEN


if not hasattr(Connection, "closed"):
Connection.closed = property(_compat_socket_closed)
if not hasattr(Connection, "open"):
Connection.open = property(_compat_socket_open)

import Utils
apname = Utils.instance_name if Utils.instance_name else "Archipelago"
Expand Down
23 changes: 12 additions & 11 deletions ModuleUpdate.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from typing import Any, List, Optional, TypeVar, override

from importlib import invalidate_caches
from BaseUtils import tuplize_version, Version, local_path, write_path, mwgg_venv_site_packages, use_worlds_venv, is_frozen
from BaseUtils import tuplize_version, Version, local_path, mwgg_venv_site_packages, use_worlds_venv, is_frozen
from APContainer import APWorldContainer

# mwgg_igdb package source — orphan branch on the Index repo
Expand Down Expand Up @@ -135,16 +135,17 @@ def update(self, *s: Iterable[_T]) -> None:
requirements_files: RequirementsSet[Path] = RequirementsSet({Path(local_path("requirements.txt"))})
worlds_files: dict[str, RequirementsSet[str]] = {"wheels": RequirementsSet(), "apworlds": RequirementsSet()}

# Frozen builds: custom_worlds lives under write_path() — i.e.
# ~/.local/share/MultiworldGG/custom_worlds on Linux, %LOCALAPPDATA% on Windows,
# ~/Library/Application Support/ on macOS — so it's user-writable on every
# install shape (AppImage's `/tmp/.mount_…` mount is read-only; tarball install
# dirs may be in /opt/ etc.). Dev mode keeps using the in-repo dir for
# convenience (drop apworlds in <repo>/custom_worlds/ and they're picked up).
if is_frozen():
custom_worlds_dir = Path(write_path("custom_worlds"))
else:
custom_worlds_dir = Path(local_path("custom_worlds"))
# custom_worlds always lives next to the executable / source checkout -- the
# upstream location, and where users actually drop their apworlds. This is the
# single source of truth for the launch scan (register_custom_worlds /
# get_available_worlds), Utils.set_game_names, and the launch path. Do NOT
# special-case frozen builds to write_path(): that splits the scan from the launch
# path and custom worlds silently stop being selectable.
def _resolve_custom_worlds_dir() -> Path:
return Path(local_path("custom_worlds"))


custom_worlds_dir = _resolve_custom_worlds_dir()

# Best-effort mkdir. Skipped silently on the rare read-only filesystem; the
# downstream readers already handle missing directories.
Expand Down
2 changes: 1 addition & 1 deletion MultiWorld.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ async def main(args: list[str]):
from Utils import register_custom_worlds
register_custom_worlds()
except Exception as e:
logger.warning(f"Could not scan custom_worlds on launch: {e}")
logger.warning(f"Could not scan custom_worlds on launch: {e}", exc_info=True)

# Run the main client in the current process
run_client(args, queue=splash_queue)
6 changes: 3 additions & 3 deletions Utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def set_game_names(game_names: typing.List[str], strict: bool = True) -> typing.
game_names = _expand_game_choices(game_names)
_worlds_to_install = {game: "" for game in game_names}
_unknown_worlds = []
custom_worlds_dir = Path(local_path("custom_worlds"))
custom_worlds_dir = ModuleUpdate.custom_worlds_dir
_unlisted_worlds = [world for world in ModuleUpdate.find_world_modules() if world not in GameIndex.game_names.values()]
# We only have the module name here, not the game name, and that is buried deep in the metadata

Expand Down Expand Up @@ -299,7 +299,7 @@ def register_custom_worlds() -> typing.List[str]:
try:
module_name = discover_custom_world_module(world_file)
except Exception as e:
update_logger.warning(f"Skipping custom world {world_file.name}: {e}")
update_logger.warning(f"Skipping custom world {world_file.name}: {e}", exc_info=True)
continue
if module_name and module_name not in found:
found.append(module_name)
Expand Down Expand Up @@ -432,7 +432,7 @@ def _install_module_threaded():
# dir, so importlib.import_module(worlds.<slug>) now works via the
# normal file loader. Just register them with the live GameIndex
# so launcher lookups (game_name -> module) resolve cleanly.
custom_worlds_dir = Path(local_path("custom_worlds"))
custom_worlds_dir = ModuleUpdate.custom_worlds_dir
for target in custom_fallbacks:
slug = target.removeprefix("worlds.")
apworld_file = custom_worlds_dir / f"{slug}.apworld"
Expand Down
141 changes: 133 additions & 8 deletions kvui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,84 @@
import logging

if os.environ.get("MWGG_FRONTEND", "gui") == "tui":
# TUI frontend is active. Per-world client wrappers (kh2, albw, ...) still import
# GameManager from here and call ctx.run_gui() during their launch. We must preserve
# the takeover handshake -- server_loop blocks on `await ctx.takeover_complete.wait()`
# until _takeover_existing_ui() runs and sets the event -- but we must NOT pull in
# Kivy. Just constructing the real (Kivy-backed) GameManager opens a rogue window on
# top of the Textual TUI; that's the leak we're fixing here.
# TUI frontend is active. Per-world client wrappers (kh2, albw, kh3, ...) still do
# `from kvui import <kivy/kivymd names>` and call ctx.run_gui() during launch. Two
# requirements collide: we must NOT import Kivy (merely importing kivy.core.window
# opens a rogue window over the Textual TUI), yet those imports must still succeed
# and the names stay usable as base classes / dp() / etc., or the client crashes
# before the takeover handshake runs (server_loop blocks on
# `await ctx.takeover_complete.wait()` until _takeover_existing_ui() sets the event).
#
# Resolution: hand every Kivy/KivyMD name an inert, non-Kivy stand-in. This is
# semantically safe because the Kivy per-world UI is never built under the TUI --
# LegacyKvuiClientBuilder.build() returns early when MWGG_FRONTEND=tui -- so the
# stand-ins only have to survive import, subclassing, instantiation and attribute
# access, never rendering. GameManager stays a real class so the takeover in its
# async_run() keeps working.

class _InertMeta(type):
"""Metaclass so stand-in *classes* tolerate attribute access too, e.g.
``Clock.schedule_interval`` or ``App.get_running_app`` used on the class."""

def __getattr__(cls, name):
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
return _INERT

class _Inert(metaclass=_InertMeta):
"""Universal inert stand-in for a Kivy/KivyMD object under the TUI frontend.

Usable as a base class; instances accept any constructor args and tolerate
being called, attribute-accessed, indexed or iterated. Calling the class
(e.g. ``StringProperty("")``) returns an inert instance.
"""

def __init__(self, *args, **kwargs):
pass

def __call__(self, *args, **kwargs):
return _INERT

def __getattr__(self, name):
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
return _INERT

def __getitem__(self, key):
return _INERT

def __bool__(self):
return False

def __iter__(self):
return iter(())

_INERT = _Inert()

def dp(value):
"""kivy.metrics.dp stand-in -- density is meaningless without a window, and
worlds use dp() in class bodies (e.g. ``height=dp(30)``), so return the value."""
return value

sp = dp
Clock = _INERT
Window = _INERT

class GameManager:
logging_pairs: list = []
base_title: str = ""

def __init__(self, ctx):
def __init__(self, ctx, *args, **kwargs):
self.ctx = ctx

def __getattr__(self, name):
# Per-world GameManager subclasses reach for Kivy-app attributes the GUI
# build would have supplied; under the TUI build() never runs, so answer
# inertly instead of crashing.
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
return _INERT

async def async_run(self):
if self.ctx._can_takeover_existing_ui():
await self.ctx._takeover_existing_ui()
Expand All @@ -41,6 +105,18 @@ def create_custom_screen(self, title, content, index=-1):
def remove_custom_screen(self, button):
pass

def __getattr__(name):
"""Serve an inert stand-in for any Kivy/KivyMD name a world client imports from
kvui under the TUI frontend (PEP 562). Cached as a module global so the class
identity is stable across imports -- multiple inheritance and any isinstance/
issubclass checks depend on it. Never imports Kivy."""
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
stub = _InertMeta(name, (_Inert,), {})
globals()[name] = stub
logging.getLogger("kvui").debug("kvui(tui): served inert stand-in for %r", name)
return stub

else:
from openpyxl.xml.constants import PACKAGE_CHARTSHEETS_RELS
from mwgg_gui.components.dialog import MessageBox
Expand All @@ -67,7 +143,6 @@ def remove_custom_screen(self, button):
from kivymd.uix.gridlayout import MDGridLayout as MainLayout
from kivymd.uix.floatlayout import MDFloatLayout as ContainerLayout
from kivymd.uix.recycleview import MDRecycleView
from kivymd.uix.behaviors import HoverBehavior
from kivymd.uix.divider import MDDivider
from kivymd.uix.label import MDLabel
from kivymd.uix.progressindicator import MDLinearProgressIndicator
Expand All @@ -77,6 +152,56 @@ def remove_custom_screen(self, button):
from kivy.uix.image import AsyncImage as ApAsyncImage
from kivymd.uix.tooltip import MDTooltipPlain as ToolTip

# Compatibility re-exports for world clients that do `from kvui import ...`.
# MultiworldGG worlds (kh3, etc.) import these canonical kivy/kivymd names from
# kvui; the GUI imports above bind some only under MWGG aliases (ToggleButton,
# MainLayout) or not at all.
from kivy.clock import Clock
from kivy.core.window import Window
from kivy.factory import Factory
from kivymd.uix.button import MDButton, MDButtonText, MDIconButton
from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.textfield.textfield import MDTextField

# World clients subclass HoverBehavior alongside kivymd widgets, e.g.
# `class X(HoverBehavior, MDIconButton)`. kivymd.uix.behaviors.HoverBehavior
# shares an MRO-incompatible base with those widgets; this plain object-based
# mixin (the original MWGG HoverBehavior) linearizes cleanly and exposes the
# hovered/border_point/on_enter/on_leave API those clients expect.
class HoverBehavior(object):
"""originally from https://stackoverflow.com/a/605348110"""
hovered = BooleanProperty(False)
border_point = ObjectProperty(None)

def __init__(self, **kwargs):
self.register_event_type("on_enter")
self.register_event_type("on_leave")
Window.bind(mouse_pos=self.on_mouse_pos)
Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs)

def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
# to_widget translates window pos to within widget pos
inside = self.collide_point(*self.to_widget(*pos))
if self.hovered == inside:
return # We have already done what was needed
self.border_point = pos
self.hovered = inside
if inside:
self.dispatch("on_enter")
else:
self.dispatch("on_leave")

def on_cursor_leave(self, *args):
# if the mouse left the window, it is no longer inside the hover widget.
self.hovered = BooleanProperty(False)
self.border_point = ObjectProperty(None)
self.dispatch("on_leave")

Factory.register("HoverBehavior", HoverBehavior)

class GameManager:
logging_pairs: list = []
base_title: str = ""
Expand Down
41 changes: 35 additions & 6 deletions test/_stubs/mwgg_igdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,22 +64,51 @@ def games(self, item) -> None:
self._games[key] = value

def search(self, query: str) -> dict:
return {}
# Faithful-enough port of the real GameIndex.search: exact-term AND, then
# substring fallback, returning {module_slug: game_data}. The launcher
# (mwgg_tui/mwgg_gui) resolves a typed query through here, so the stub must
# actually search rather than return {} -- otherwise a regression where a
# custom world never lands in the search index would pass unnoticed.
if not query:
return {}
terms = query.lower().split()
exact = [self._search_index[t] for t in terms if t in self._search_index]
matching: set = set()
if exact:
matching = set(exact[0])
for s in exact[1:]:
matching &= s
if not matching:
for term in terms:
for indexed_term, slugs in self._search_index.items():
if term in indexed_term:
matching |= slugs
return {slug: self._games.get(slug, {}) for slug in matching}

def get_game(self, game_module: str) -> dict:
return self._games.get(game_module, {})

def _index_value(self, game_module: str, value) -> None:
# Mirror real GameIndex._index_value: index the full lowercased string and
# each whitespace word, so a world is searchable by its display name.
if not value:
return
cleaned = str(value).lower()
if not cleaned:
return
self.search_index = cleaned, game_module
for word in cleaned.split():
self.search_index = word, game_module

def add_game(self, game_module: str, game_data: dict) -> None:
self._games[game_module] = game_data
name = game_data.get("game_name")
if name:
self._game_names[name] = game_module
self._module_to_name[game_module] = name
for term in game_module.lower().split():
if term in self._search_index:
self._search_index[term].add(game_module)
else:
self._search_index[term] = {game_module}
# Searchable by display name (mirrors the real module) and by slug.
self._index_value(game_module, name)
self._index_value(game_module, game_module.replace("_", " "))

def get_module_for_game(self, game_name: str, worlds: bool = False):
module = self._game_names.get(game_name)
Expand Down
Loading