From bbcd90a82357d889386c658202d9c786194742e1 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 5 Nov 2025 17:05:19 -0600 Subject: [PATCH 01/97] feat!: Switch to Cyclopts for CLI - Add `--config-directory`, `--games-directory` parameters. - Allow configuring with environment variables. BREAKING: - Options with choices use hyphens. Ex: `--game lotro_preview` -> `--game lotro-preview` - `--startup-script` -> `--startup-scripts` - Shell completions need to be reinstalled. --- README.md | 108 +-- flake.nix | 14 + pyproject.toml | 9 +- src/onelauncher/__main__.py | 10 +- src/onelauncher/async_utils.py | 43 +- src/onelauncher/cli.py | 800 +++++++++----------- src/onelauncher/config_manager.py | 60 +- src/onelauncher/game_config.py | 17 +- src/onelauncher/main.py | 111 +++ src/onelauncher/main_window.py | 22 +- src/onelauncher/program_config.py | 2 +- src/onelauncher/settings_window.py | 2 +- tests/onelauncher/test_cli.py | 182 +++++ uv.lock | 1104 +++++++++++++++------------- 14 files changed, 1425 insertions(+), 1059 deletions(-) create mode 100644 src/onelauncher/main.py create mode 100644 tests/onelauncher/test_cli.py diff --git a/README.md b/README.md index 227ff8ac..b0b44d83 100644 --- a/README.md +++ b/README.md @@ -42,50 +42,74 @@ Most people should just need to [install WINE](https://github.com/lutris/docs/bl ## Command Line Usage -All settings can be overridden from the command line. This is especially useful for making custom shortcuts. For example, loading the LOTRO preview client in French could be done with `--game lotro_preview --locale fr`. +All settings can be overridden from the command line. This is especially useful for making custom shortcuts. For example, loading the LOTRO preview client in French could be done with `--game lotro-preview --locale fr`. ```txt -OneLauncher 2.0 - - Usage: onelauncher [OPTIONS] COMMAND [ARGS]... - -╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --version Print version and exit. │ -│ --install-completion Install completion for the current shell. │ -│ --show-completion Show completion for the current shell, to copy it or customize the installation. │ -│ --help -h Show this message and exit. │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Program Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --default-locale TEXT The default language for games and UI. │ -│ --always-use-default-locale-for-ui --no-always-use-default-locale-for-ui Use default language for UI regardless of game language │ -│ --games-sorting-mode [priority|last_played|alphabetical] Order to show games in UI │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Game Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --game GAME_TYPE_OR_ID Which game to load. (lotro, lotro_preview, ddo, ddo_preview, or a game config ID) │ -│ --game-directory DIRECTORY The game's install directory │ -│ --locale TEXT Language used for game │ -│ --client-type [WIN64|WIN32|WIN32Legacy] Which version of the game client to use │ -│ --high-res-enabled --no-high-res-enabled If the high resolution game files should be used │ -│ --standard-game-launcher-filename TEXT The name of the standard game launcher executable. Ex. LotroLauncher.exe │ -│ --patch-client-filename TEXT Name of the dll used for game patching. Ex. patchclient.dll │ -│ --game-settings-directory DIRECTORY Custom game settings directory. This is where user preferences, screenshots, and │ -│ addons are stored. │ -│ --newsfeed TEXT URL of the feed (RSS, ATOM, ect) to show in the launcher │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Game Account Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --username TEXT Login username │ -│ --display-name TEXT Name shown instead of account name │ -│ --last-used-world-name TEXT World last logged into. Will be the default at next login │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Game Addons Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --startup-script FILE Python scripts run before game launch. Paths are relative to the game's documents config directory │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Game WINE Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --builtin-prefix-enabled --no-builtin-prefix-enabled If WINE should be automatically managed │ -│ --user-wine-executable-path FILE Path to the WINE executable to use when WINE isn't automatically managed │ -│ --user-prefix-path DIRECTORY Path to the WINE prefix to use when WINE isn't automatically managed │ -│ --wine-debug-level TEXT WINE debug level to use │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +Usage: onelauncher COMMAND [OPTIONS] + +Environment variables can also be used. For example, --config-directory can be +set with ONELAUNCHER_CONFIG_DIRECTORY. + +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ --help -h Display this message and exit. │ +│ --install-completion Install shell completion for this application. │ +│ --version Display application version. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Parameters ─────────────────────────────────────────────────────────────────╮ +│ --game Which game to load. Can be either a game type or game │ +│ config ID. [choices: lotro, lotro-preview, ddo, │ +│ ddo-preview] │ +│ --config-directory Where OneLauncher settings are stored [default: │ +│ /home/june/.config/onelauncher] │ +│ --games-directory Where OneLauncher game specific data is stored [default: │ +│ /home/june/.local/share/onelauncher/games] │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Program Options ────────────────────────────────────────────────────────────╮ +│ --default-locale Default language for games and UI │ +│ --always-use-default-locale- Use default language for UI regardless of game │ +│ for-ui --no-always-use-def language │ +│ ault-locale-for-ui │ +│ --games-sorting-mode Order to show games in UI [choices: priority, │ +│ last-played, alphabetical] │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Game Options ───────────────────────────────────────────────────────────────╮ +│ --game-directory The game's install directory │ +│ --locale Language used for game │ +│ --client-type Which version of the game client to use │ +│ [choices: win64, win32, win32-legacy, │ +│ win32-legacy] │ +│ --high-res-enabled If the high resolution game files should be │ +│ --no-high-res-enabled used │ +│ --standard-game-launcher-fil Name of the standard game launcher executable. │ +│ ename Ex. LotroLauncher.exe │ +│ --patch-client-filename Name of the dll used for game patching. Ex. │ +│ patchclient.dll │ +│ --game-settings-directory Custom game settings directory. This is where │ +│ user preferences, screenshots, and addons are │ +│ stored. │ +│ --newsfeed URL of the feed (RSS, ATOM, ect) to show in │ +│ the launcher │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Game Account Options ───────────────────────────────────────────────────────╮ +│ --username Login username │ +│ --display-name Name shown instead of account name │ +│ --last-used-world-name World last logged into. Will be the default at next │ +│ login │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Game Addons Options ────────────────────────────────────────────────────────╮ +│ --startup-scripts Python scripts run before game launch. Paths are │ +│ --empty-startup-scripts relative to the game's documents config directory │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ Game WINE Options ──────────────────────────────────────────────────────────╮ +│ --builtin-prefix-enabled If WINE should be automatically managed │ +│ --no-builtin-prefix-enable │ +│ d │ +│ --user-wine-executable-path Path to the WINE executable to use when WINE │ +│ isn't automatically managed │ +│ --user-prefix-path Path to the WINE prefix to use when WINE isn't │ +│ automatically managed │ +│ --wine-debug-level Value for the WINEDEBUG environment variable │ +╰──────────────────────────────────────────────────────────────────────────────╯ ``` ## Contributing diff --git a/flake.nix b/flake.nix index 725ab13e..aa9dfea8 100644 --- a/flake.nix +++ b/flake.nix @@ -243,6 +243,20 @@ pkgs.mkShell { packages = [ virtualenv + (pkgs.runCommand "onelauncher-shell-completions" + { + nativeBuildInputs = [ + self.packages.${system}.onelauncher + pkgs.installShellFiles + ]; + } + '' + installShellCompletion --cmd onelauncher \ + --bash <(onelauncher generate-shell-completion bash) \ + --fish <(onelauncher generate-shell-completion fish) \ + --zsh <(onelauncher generate-shell-completion zsh) + '' + ) pkgs.uv # Used for Nuitka compilation caching pkgs.ccache diff --git a/pyproject.toml b/pyproject.toml index ac60c935..c3d300bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,8 @@ dependencies = [ "asyncache", "attrs>=24.2.0", "cattrs[tomlkit]>=23.2.3", - "typer>=0.12.4", "packaging>=24.1", + "cyclopts>=4.3.0", ] [project.urls] @@ -47,14 +47,14 @@ Issues = "https://github.com/JuneStepp/OneLauncher/issues" ChangeLog = "https://github.com/JuneStepp/OneLauncher/blob/main/CHANGES.md" [project.scripts] -onelauncher = "onelauncher.cli:app" +onelauncher = "onelauncher.__main__:main" [dependency-groups] lint = [ "mypy>=1.18.2", "types-cachetools>=5.3.0.7", "ruff>=0.14.1", - # Newer versions have better types + # Newer versions have more accurate types. "PySide6-Essentials>=6.10.0", ] test = [ @@ -62,6 +62,8 @@ test = [ "pytest-randomly>=3.15.0", # Used to test mypy plugin "mypy", + "pytest-mock>=3.15.1", + "pytest-trio>=0.8.0", ] build = ["Nuitka>=2.4.8", "marko>=2.1.2"] dev = [ @@ -127,6 +129,7 @@ addopts = ["--import-mode=importlib", "--strict-markers", "--strict-config"] testpaths = ["tests"] filterwarnings = ["error"] xfail_strict = true +trio_mode = true [tool.mypy] plugins = ["onelauncher.mypy_plugin"] diff --git a/src/onelauncher/__main__.py b/src/onelauncher/__main__.py index f8cd76f3..0e6ec43c 100644 --- a/src/onelauncher/__main__.py +++ b/src/onelauncher/__main__.py @@ -1,3 +1,9 @@ -from .cli import app +from . import cli -app() + +def main() -> None: + cli.get_app()() + + +if __name__ == "__main__": + main() diff --git a/src/onelauncher/async_utils.py b/src/onelauncher/async_utils.py index 62211c7a..9f54e879 100644 --- a/src/onelauncher/async_utils.py +++ b/src/onelauncher/async_utils.py @@ -1,16 +1,19 @@ import logging from collections.abc import Awaitable, Callable -from typing import Any +from functools import partial +from typing import Final import outcome import trio from PySide6 import QtCore, QtWidgets from typing_extensions import override +from onelauncher.qtapp import get_qapp + logger = logging.getLogger(__name__) -# Top-level cancel scope. Canceling it will exit the program. -app_cancel_scope = trio.CancelScope() +app_cancel_scope: Final = trio.CancelScope() +"""Top-level cancel scope. Canceling it will exit the program.""" class AsyncHelper(QtCore.QObject): @@ -32,11 +35,11 @@ class ReenterQtEvent(QtCore.QEvent): """This is the QEvent that will be handled by the ReenterQtObject. self.fn is the next entry point of the Trio event loop.""" - def __init__(self, fn: Callable[[], Any]): + def __init__(self, fn: Callable[[], object]): super().__init__(QtCore.QEvent.Type(QtCore.QEvent.Type.User + 1)) self.fn = fn - def __init__(self, entry: Callable[[], Awaitable[Any]]): + def __init__(self, entry: Callable[[], Awaitable[object]]): super().__init__() self.reenter_qt = self.ReenterQtObject() self.entry = entry @@ -53,7 +56,7 @@ def launch_guest_run(self) -> None: strict_exception_groups=True, ) - def next_guest_run_schedule(self, fn: Callable[[], Any]) -> None: + def next_guest_run_schedule(self, fn: Callable[[], object]) -> None: """ This function serves to re-schedule the guest (Trio) event loop inside the host (Qt) event loop. It is called by Trio @@ -64,7 +67,7 @@ def next_guest_run_schedule(self, fn: Callable[[], Any]) -> None: """ QtWidgets.QApplication.postEvent(self.reenter_qt, self.ReenterQtEvent(fn)) - def trio_done_callback(self, run_outcome: outcome.Outcome[Any]) -> None: + def trio_done_callback(self, run_outcome: outcome.Outcome[object]) -> None: """This function is called by Trio when its event loop has finished.""" if isinstance(run_outcome, outcome.Error): @@ -73,3 +76,29 @@ def trio_done_callback(self, run_outcome: outcome.Outcome[Any]) -> None: if qapp := QtCore.QCoreApplication.instance(): qapp.exit() + + +async def _scope_entry(entry: Callable[[], Awaitable[None]]) -> None: + with app_cancel_scope: + await entry() + + +def start_async(entry: Callable[[], Awaitable[None]]) -> int: + """ + Returns: + int: Exit code + """ + trio.run(partial(_scope_entry, entry=entry)) + return 0 + + +def start_async_gui(entry: Callable[[], Awaitable[None]]) -> int: + """ + Returns: + int: Exit code + """ + qapp = get_qapp() + async_helper = AsyncHelper(partial(_scope_entry, entry=entry)) + QtCore.QTimer.singleShot(0, async_helper.launch_guest_run) + # qapp.exec() won't return until trio event loop finishes. + return qapp.exec() diff --git a/src/onelauncher/cli.py b/src/onelauncher/cli.py index 774f889d..32741fa5 100644 --- a/src/onelauncher/cli.py +++ b/src/onelauncher/cli.py @@ -1,83 +1,83 @@ -from __future__ import annotations - import inspect import logging import os import subprocess -import sys import sysconfig -import traceback -from collections.abc import Awaitable, Callable, Iterator -from enum import StrEnum +from collections.abc import Sequence +from enum import Enum from functools import partial from pathlib import Path -from typing import Annotated, assert_never +from typing import ( + Annotated, + Literal, + TypeVar, + assert_never, +) import attrs -import click -import rich -import typer -from PySide6 import QtCore, QtWidgets -from typer.core import TyperGroup as TyperGroupBase -from typing_extensions import override +import cyclopts +from cyclopts import Parameter, Token +from cyclopts.types import ( + ResolvedDirectory, + ResolvedExistingDirectory, + ResolvedExistingFile, +) import onelauncher +from onelauncher.main import start_ui, verify_configs from .__about__ import __title__, __version__, version_parsed from .addons.config import AddonsConfigSection from .addons.startup_script import StartupScript -from .async_utils import AsyncHelper, app_cancel_scope +from .async_utils import start_async_gui from .config import ConfigFieldMetadata from .config_manager import ( - ConfigFileError, + GAMES_DIR_DEFAULT, + PROGRAM_CONFIG_DIR_DEFAULT, ConfigManager, - WrongConfigVersionError, + NoValidGamesError, get_converter, ) from .game_account_config import GameAccountConfig, GameAccountsConfig from .game_config import ClientType, GameConfig, GameConfigID, GameType from .logs import setup_application_logging from .program_config import GamesSortingMode, ProgramConfig -from .qtapp import get_qapp from .resources import OneLauncherLocale -from .setup_wizard import SetupWizard from .ui import qtdesigner -from .ui.error_message_uic import Ui_errorDialog from .utilities import CaseInsensitiveAbsolutePath from .wine.config import WineConfigSection logger = logging.getLogger(__name__) -class TyperGroup(TyperGroupBase): - """Custom TyperGroup class.""" +class _GameParamGameType(Enum): + LOTRO = "lotro" + LOTRO_PREVIEW = "lotro_preview" + DDO = "ddo" + DDO_PREVIEW = "ddo_preview" - @override - def get_usage(self, context: click.Context) -> str: - """Add app title above usage section""" - usage = super().get_usage(context) - return f"{__title__} {__version__} \n\n {usage}" +_ConverterTypeVar = TypeVar("_ConverterTypeVar", bound=type) -app = typer.Typer( - context_settings={"help_option_names": ["--help", "-h"]}, - rich_markup_mode="rich", - pretty_exceptions_show_locals=False, - pretty_exceptions_enable=False, - cls=TyperGroup, -) +@Parameter(n_tokens=1) +def _cattrs_converter( + type_: type[_ConverterTypeVar], tokens: Sequence[Token] +) -> _ConverterTypeVar: + converter = get_converter() + return converter.structure(tokens[0].value, type_) -def version_calback(value: bool) -> None: - if value: - rich.print(f"[bold]{__title__}[/bold] [cyan]{__version__}[/cyan]") - raise typer.Exit() + +def _get_help(field_name: str, /, attrs_class: type[attrs.AttrsInstance]) -> str | None: + return ConfigFieldMetadata.from_field_name( + field_name=field_name, attrs_class=attrs_class + ).help -def merge_program_config( +def _merge_program_config( program_config: ProgramConfig, *, - default_locale: str | None, + default_locale: OneLauncherLocale | None, always_use_default_locale_for_ui: bool | None, games_sorting_mode: GamesSortingMode | None, ) -> ProgramConfig: @@ -85,16 +85,9 @@ def merge_program_config( Merge `program_config` with CLI options. Any specified CLI options will override the existing values in `program_config`. """ - converter = get_converter() - default_locale_structured = ( - converter.structure(default_locale, OneLauncherLocale) - if default_locale - else None - ) - return attrs.evolve( program_config, - default_locale=(default_locale_structured or program_config.default_locale), + default_locale=(default_locale or program_config.default_locale), always_use_default_locale_for_ui=( always_use_default_locale_for_ui if always_use_default_locale_for_ui is not None @@ -104,19 +97,19 @@ def merge_program_config( ) -def merge_game_config( +def _merge_game_config( game_config: GameConfig, *, - game_directory: Path | None, - locale: str | None, + game_directory: CaseInsensitiveAbsolutePath | None, + locale: OneLauncherLocale | None, client_type: ClientType | None, high_res_enabled: bool | None, standard_game_launcher_filename: str | None, patch_client_filename: str | None, - game_settings_directory: Path | None, + game_settings_directory: CaseInsensitiveAbsolutePath | None, newsfeed: str | None, # Addons Section - enabled_startup_scripts: list[Path] | None, + enabled_startup_scripts: tuple[Path, ...] | None, # WINE section builtin_prefix_enabled: bool | None, user_wine_executable_path: Path | None, @@ -128,10 +121,6 @@ def merge_game_config( override the existing values in `game_config`. """ converter = get_converter() - locale_structured = ( - converter.structure(locale, OneLauncherLocale) if locale else None - ) - startup_scripts_structured = ( tuple( converter.structure(script, StartupScript) @@ -175,13 +164,9 @@ def merge_game_config( return attrs.evolve( game_config, game_directory=( - CaseInsensitiveAbsolutePath(game_directory) - if game_directory is not None - else game_config.game_directory - ), - locale=( - locale_structured if locale_structured is not None else game_config.locale + game_directory if game_directory is not None else game_config.game_directory ), + locale=(locale if locale is not None else game_config.locale), client_type=( client_type if client_type is not None else game_config.client_type ), @@ -201,7 +186,7 @@ def merge_game_config( else game_config.patch_client_filename ), game_settings_directory=( - CaseInsensitiveAbsolutePath(game_settings_directory) + game_settings_directory if game_settings_directory is not None else game_config.game_settings_directory ), @@ -211,7 +196,7 @@ def merge_game_config( ) -def merge_accounts_config( +def _merge_accounts_config( game_accounts_config: GameAccountsConfig, *, username: str | None, @@ -251,387 +236,328 @@ def merge_accounts_config( return attrs.evolve(game_accounts_config, accounts=tuple(accounts)) -class GameOptions(StrEnum): - LOTRO = "lotro" - LOTRO_PREVIEW = "lotro_preview" - DDO = "ddo" - DDO_PREVIEW = "ddo_preview" - - -def game_type_or_id(value: str) -> str: - if value.lower() in list(GameOptions): - return value.lower() - return value - - -def _parse_game_arg(game_arg: str, config_manager: ConfigManager) -> GameConfigID: - """ - Raises: - typer.BadParameter - """ - game_ids = config_manager.get_games_sorted( - config_manager.get_program_config().games_sorting_mode - ) - - # Handle game config ID game arg - if game_arg not in tuple(GameOptions): - game_id = game_arg - if game_id not in game_ids: - raise typer.BadParameter( - message="Provided game config ID does not exist", param_hint="--game" - ) - return game_id - - game_option = GameOptions(game_arg) - match game_option: - case GameOptions.LOTRO: - game_type = GameType.LOTRO - is_preview = False - case GameOptions.LOTRO_PREVIEW: - game_type = GameType.LOTRO - is_preview = True - case GameOptions.DDO: - game_type = GameType.DDO - is_preview = False - case GameOptions.DDO_PREVIEW: - game_type = GameType.DDO - is_preview = True - case _: - assert_never(game_option) - for game_id in game_ids: - game_config = config_manager.read_game_config_file(game_id=game_id) - if ( - game_config.game_type == game_type - and game_config.is_preview_client == is_preview - ): - return game_id - raise typer.BadParameter(message=f"No {game_arg} games exist", param_hint="--game") - - -def _complete_game_arg(incomplete: str) -> Iterator[str]: - config_manager = ConfigManager(lambda c: c, lambda c: c, lambda c: c) - try: - config_manager.verify_configs() - game_ids = config_manager.get_game_config_ids() - except ConfigFileError: - game_ids = () - for option in tuple(GameOptions) + game_ids: - if option.startswith(incomplete): - yield option - - -def _complete_username_arg(incomplete: str, context: typer.Context) -> Iterator[str]: - game_arg: str | None = context.params.get("game") - if not game_arg: - return - config_manager = ConfigManager(lambda c: c, lambda c: c, lambda c: c) - config_manager.verify_configs() - try: - game_id = _parse_game_arg(game_arg=game_arg, config_manager=config_manager) - except typer.BadParameter: - return - usernames = ( - account.username - for account in config_manager.read_game_accounts_config_file(game_id) - ) - for option in usernames: - if option.startswith(incomplete): - yield option - - -ProgramOption = partial( - typer.Option, show_default=False, rich_help_panel="Program Options" -) -GameOption = partial(typer.Option, show_default=False, rich_help_panel="Game Options") -AccountOption = partial( - typer.Option, show_default=False, rich_help_panel="Game Account Options" -) -AddonsOption = partial( - typer.Option, show_default=False, rich_help_panel="Game Addons Options" -) -WineOption = partial( - typer.Option, - show_default=False, - rich_help_panel="Game WINE Options", - hidden=os.name == "nt", +ProgramGroup = cyclopts.Group.create_ordered(name="Program Options") +GameGroup = cyclopts.Group.create_ordered(name="Game Options") +AccountGroup = cyclopts.Group.create_ordered(name="Game Account Options") +AddonsGroup = cyclopts.Group.create_ordered(name="Game Addons Options") +WineGroup = cyclopts.Group.create_ordered( + name="Game WINE Options", show=os.name != "nt" ) -DevOption = partial( - typer.Option, - show_default=True, - rich_help_panel="Dev Stuff", - hidden=not version_parsed.is_devrelease, +DevGroup = cyclopts.Group.create_ordered( + name="Dev Stuff", show=version_parsed.is_devrelease ) -dev_command = partial( - app.command, - hidden=not version_parsed.is_devrelease, - rich_help_panel="Dev Stuff", -) - - -def get_help(field_name: str, /, attrs_class: type[attrs.AttrsInstance]) -> str | None: - return ConfigFieldMetadata.from_field_name( - field_name=field_name, attrs_class=attrs_class - ).help - -prog_help = partial(get_help, attrs_class=ProgramConfig) -game_help = partial(get_help, attrs_class=GameConfig) -account_help = partial(get_help, attrs_class=GameAccountConfig) -addons_help = partial(get_help, attrs_class=AddonsConfigSection) -wine_help = partial(get_help, attrs_class=WineConfigSection) +prog_help = partial(_get_help, attrs_class=ProgramConfig) +game_help = partial(_get_help, attrs_class=GameConfig) +account_help = partial(_get_help, attrs_class=GameAccountConfig) +addons_help = partial(_get_help, attrs_class=AddonsConfigSection) +wine_help = partial(_get_help, attrs_class=WineConfigSection) -@app.callback(invoke_without_command=True) -def main( - context: typer.Context, - version: Annotated[ - bool, - typer.Option( - "--version", - help="Print version and exit.", - is_eager=True, - callback=version_calback, - ), - ] = False, - # Program options - default_locale: Annotated[ - str | None, ProgramOption(help=prog_help("default_locale")) - ] = None, - always_use_default_locale_for_ui: Annotated[ - bool | None, - ProgramOption(help=prog_help("always_use_default_locale_for_ui")), - ] = None, - games_sorting_mode: Annotated[ - GamesSortingMode | None, ProgramOption(help=prog_help("games_sorting_mode")) - ] = None, - # Game options - game: Annotated[ - str | None, - GameOption( - help=( - "Which game to load. ([yellow]" - f"{', '.join(GameOptions)}, or a game config ID)" - ), - parser=game_type_or_id, - autocompletion=_complete_game_arg, - ), - ] = None, - game_directory: Annotated[ - Path | None, - GameOption( - help=game_help("game_directory"), - exists=True, - file_okay=False, - dir_okay=True, - resolve_path=True, - ), - ] = None, - locale: Annotated[str | None, GameOption(help=game_help("locale"))] = None, - client_type: Annotated[ - ClientType | None, - GameOption(help=game_help("client_type"), case_sensitive=False), - ] = None, - high_res_enabled: Annotated[ - bool | None, GameOption(help=game_help("high_res_enabled")) - ] = None, - standard_game_launcher_filename: Annotated[ - str | None, GameOption(help=game_help("standard_game_launcher_filename")) - ] = None, - patch_client_filename: Annotated[ - str | None, GameOption(help=game_help("patch_client_filename")) - ] = None, - game_settings_directory: Annotated[ - Path | None, - GameOption( - help=game_help("game_settings_directory"), - exists=False, - file_okay=False, - dir_okay=True, - resolve_path=True, +def get_app() -> cyclopts.App: + app = cyclopts.App( + name=__title__.lower(), + version=__version__, + help=( + "Environment variables can also be used. For example, `--config-directory` " + "can be set with `ONELAUNCHER_CONFIG_DIRECTORY`." ), - ] = None, - newsfeed: Annotated[str | None, GameOption(help=game_help("newsfeed"))] = None, - # Account options - username: Annotated[ - str | None, - AccountOption( - help=account_help("username"), autocompletion=_complete_username_arg - ), - ] = None, - display_name: Annotated[ - str | None, AccountOption(help=account_help("display_name")) - ] = None, - last_used_world_name: Annotated[ - str | None, AccountOption(help=account_help("last_used_world_name")) - ] = None, - # Addons options - startup_script: Annotated[ - list[Path] | None, - AddonsOption( - help=addons_help("enabled_startup_scripts"), - file_okay=True, - dir_okay=False, - resolve_path=False, - exists=False, - ), - ] = None, - # Game WINE options - builtin_prefix_enabled: Annotated[ - bool | None, WineOption(help=wine_help("builtin_prefix_enabled")) - ] = None, - user_wine_executable_path: Annotated[ - Path | None, - WineOption( - help=wine_help("user_wine_executable_path"), - exists=True, - file_okay=True, - dir_okay=False, - resolve_path=True, - ), - ] = None, - user_prefix_path: Annotated[ - Path | None, - WineOption( - help=wine_help("user_prefix_path"), - exists=True, - file_okay=False, - dir_okay=True, - resolve_path=True, - ), - ] = None, - wine_debug_level: Annotated[ - str | None, WineOption(help=wine_help("debug_level")) - ] = None, -) -> None: - # Don't run when other command or autocompletion is invoked - if context.invoked_subcommand is not None or context.resilient_parsing: - return - setup_application_logging() - get_merged_program_config = partial( - merge_program_config, - default_locale=default_locale, - always_use_default_locale_for_ui=always_use_default_locale_for_ui, - games_sorting_mode=games_sorting_mode, - ) - get_merged_game_config = partial( - merge_game_config, - game_directory=game_directory, - locale=locale, - client_type=client_type, - high_res_enabled=high_res_enabled, - standard_game_launcher_filename=standard_game_launcher_filename, - patch_client_filename=patch_client_filename, - game_settings_directory=game_settings_directory, - newsfeed=newsfeed, - # Addons Section - enabled_startup_scripts=startup_script, - # WINE Section - builtin_prefix_enabled=builtin_prefix_enabled, - user_wine_executable_path=user_wine_executable_path, - user_prefix_path=user_prefix_path, - wine_debug_level=wine_debug_level, + config=cyclopts.config.Env(prefix=f"{__title__.upper()}_", show=False), + default_parameter=Parameter(consume_multiple=True), ) - get_merged_game_accounts_config = partial( - merge_accounts_config, - username=username, - display_name=display_name, - last_used_world_name=last_used_world_name, - ) - config_manager = ConfigManager( - get_merged_program_config=get_merged_program_config, - get_merged_game_config=get_merged_game_config, - get_merged_game_accounts_config=get_merged_game_accounts_config, - ) - qapp = get_qapp() - entry = partial(_start_ui, config_manager=config_manager, game_arg=game) - async_helper = AsyncHelper(partial(_main, entry=entry)) - QtCore.QTimer.singleShot(0, async_helper.launch_guest_run) - # qapp.exec() won't return until trio event loop finishes - sys.exit(qapp.exec()) - - -@dev_command() -def designer() -> None: - """Start pyside6-designer with correct environment variables""" - env = os.environ.copy() - env["PYTHONPATH"] = ( - f"{env['PYTHONPATH']}{os.pathsep}" if "PYTHONPATH" in env else "" - ) - env["PYTHONPATH"] += ( - f"{sysconfig.get_path('purelib')}{os.pathsep}{Path(inspect.getabsfile(onelauncher)).parent.parent}" - ) - env["PYSIDE_DESIGNER_PLUGINS"] = str(Path(inspect.getabsfile(qtdesigner)).parent) - if nix_python := os.environ.get("NIX_PYTHON_ENV"): - # Trick pyside6-designer into setting the right LD_PRELOAD path for Python - # in Nix flake instead of the bare library name. - env["PYENV_ROOT"] = nix_python - subprocess.run( - "pyside6-designer", # noqa: S607 - env=env, - check=True, - ) - + _config_manager: ConfigManager | None = None + _game_id: GameConfigID | None = None + + def parse_game_arg( + game_arg: _GameParamGameType | GameConfigID, config_manager: ConfigManager + ) -> GameConfigID: + """ + Raises: + ValueError + """ + game_ids = config_manager.get_games_sorted( + config_manager.get_program_config().games_sorting_mode + ) -async def _main(entry: Callable[[], Awaitable[None]]) -> None: - with app_cancel_scope: - await entry() - - -async def _start_ui(config_manager: ConfigManager, game_arg: str | None) -> None: - try: - config_manager.verify_configs() - except ConfigFileError as e: - if ( - isinstance(e, WrongConfigVersionError) - and e.config_file_version < e.config_class.get_config_version() - ): - # This is where code to handle config migrations from 2.0+ config files should go. - raise e - logger.exception("") - dialog = QtWidgets.QDialog() - ui = Ui_errorDialog() - ui.setupUi(dialog) - ui.textLabel.setText(e.msg) - ui.detailsTextEdit.setPlainText(traceback.format_exc()) - config_backup_path = config_manager.get_config_backup_path( - config_path=e.config_file_path + if isinstance(game_arg, _GameParamGameType): + match game_arg: + case _GameParamGameType.LOTRO: + game_type = GameType.LOTRO + is_preview = False + case _GameParamGameType.LOTRO_PREVIEW: + game_type = GameType.LOTRO + is_preview = True + case _GameParamGameType.DDO: + game_type = GameType.DDO + is_preview = False + case _GameParamGameType.DDO_PREVIEW: + game_type = GameType.DDO + is_preview = True + case _: + assert_never(game_arg) + for game_id in game_ids: + game_config = config_manager.read_game_config_file(game_id=game_id) + if ( + game_config.game_type == game_type + and game_config.is_preview_client == is_preview + ): + return game_id + raise ValueError(f"No {game_arg} games exist") + + if game_arg in game_ids: + return game_arg + else: + raise ValueError("Provided game type or game config ID does not exist") + + def validate_game_param( + type_: type[_GameParamGameType | GameConfigID | None], + value: _GameParamGameType | GameConfigID | None, + ) -> None: + if isinstance(value, _GameParamGameType | GameConfigID) and _config_manager: + parse_game_arg(game_arg=value, config_manager=_config_manager) + + # --- Comands --- + # They all return an exit code integer. + + @app.meta.meta.default + def meta_meta( + *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], + config_directory: Annotated[ + ResolvedDirectory, + Parameter(help=f"Where {__title__} settings are stored"), + ] = PROGRAM_CONFIG_DIR_DEFAULT, + games_directory: Annotated[ + ResolvedDirectory, + Parameter(help=f"Where {__title__} game specific data is stored"), + ] = GAMES_DIR_DEFAULT, + ) -> int: + nonlocal _config_manager + _config_manager = ConfigManager( + program_config_dir=config_directory, + games_dir=games_directory, ) - if config_backup_path.exists(): - ui.buttonBox.addButton("Load Backup", ui.buttonBox.ButtonRole.AcceptRole) - # Replace config with backup, if the user clicks the "Load Backup" button - if dialog.exec() == dialog.DialogCode.Accepted: - e.config_file_path.unlink() - config_backup_path.rename(e.config_file_path) - return await _start_ui(config_manager=config_manager, game_arg=game_arg) + if not verify_configs(config_manager=_config_manager): + return 1 + + _command, bound, _ignored = app.meta.parse_args(tokens) + return meta(*bound.args, **bound.kwargs, config_manager=_config_manager) + + @app.meta.default + def meta( + *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], + config_manager: Annotated[ConfigManager, Parameter(parse=False)], + # Program options + default_locale: Annotated[ + OneLauncherLocale | None, + Parameter( + group=ProgramGroup, + help=prog_help("default_locale"), + converter=_cattrs_converter, + ), + ] = None, + always_use_default_locale_for_ui: Annotated[ + bool | None, + Parameter( + group=ProgramGroup, help=prog_help("always_use_default_locale_for_ui") + ), + ] = None, + games_sorting_mode: Annotated[ + GamesSortingMode | None, + Parameter(group=ProgramGroup, help=prog_help("games_sorting_mode")), + ] = None, + game: Annotated[ + _GameParamGameType | GameConfigID | None, + Parameter( + help=( + "Which game to load. Can be either a game type or game config ID." + ), + validator=validate_game_param, + ), + ] = None, + ) -> int: + config_manager.get_merged_program_config = partial( + _merge_program_config, + default_locale=default_locale, + always_use_default_locale_for_ui=always_use_default_locale_for_ui, + games_sorting_mode=games_sorting_mode, + ) + nonlocal _game_id + if game is None: + try: + _game_id = config_manager.get_initial_game() + except NoValidGamesError: + _game_id = None + else: + _game_id = parse_game_arg(game_arg=game, config_manager=config_manager) + + command, bound, _ignored = app.parse_args(tokens) + if command is default: + return default(*bound.args, **bound.kwargs, config_manager=config_manager) + elif command is app["--install-completion"].default_command: + command(*bound.args, **bound.kwargs) + return 0 else: - dialog.exec() - return - - # Run setup wizard - if not config_manager.program_config_path.exists(): - setup_wizard = SetupWizard(config_manager) - if setup_wizard.exec() == QtWidgets.QDialog.DialogCode.Rejected: - # Close program if the user left the setup wizard without finishing - return - return await _start_ui(config_manager=config_manager, game_arg=game_arg) - - # Just run the games selection portion of the setup wizard - if not config_manager.get_game_config_ids(): - QtWidgets.QMessageBox.information( - None, - "No Games Found", - f"No games have been registered with {__title__}.\n Opening games management wizard.", + raise ValueError(f"Unhandled command: {command}") + + @app.default + def default( + *, + config_manager: Annotated[ConfigManager, Parameter(parse=False)], + # Game options + game_directory: Annotated[ + CaseInsensitiveAbsolutePath | None, + Parameter( + group=GameGroup, + help=game_help("game_directory"), + converter=_cattrs_converter, + validator=cyclopts.validators.Path(exists=True, file_okay=False), + ), + ] = None, + locale: Annotated[ + OneLauncherLocale | None, + Parameter( + group=GameGroup, help=game_help("locale"), converter=_cattrs_converter + ), + ] = None, + client_type: Annotated[ + ClientType | None, + Parameter(group=GameGroup, help=game_help("client_type")), + ] = None, + high_res_enabled: Annotated[ + bool | None, Parameter(group=GameGroup, help=game_help("high_res_enabled")) + ] = None, + standard_game_launcher_filename: Annotated[ + str | None, + Parameter( + group=GameGroup, help=game_help("standard_game_launcher_filename") + ), + ] = None, + patch_client_filename: Annotated[ + str | None, + Parameter(group=GameGroup, help=game_help("patch_client_filename")), + ] = None, + game_settings_directory: Annotated[ + CaseInsensitiveAbsolutePath | None, + Parameter( + group=GameGroup, + help=game_help("game_settings_directory"), + converter=_cattrs_converter, + validator=cyclopts.validators.Path(file_okay=False), + ), + ] = None, + newsfeed: Annotated[ + str | None, Parameter(group=GameGroup, help=game_help("newsfeed")) + ] = None, + # Account options + username: Annotated[ + str | None, + Parameter( + group=AccountGroup, + help=account_help("username"), + ), + ] = None, + display_name: Annotated[ + str | None, Parameter(group=AccountGroup, help=account_help("display_name")) + ] = None, + last_used_world_name: Annotated[ + str | None, + Parameter(group=AccountGroup, help=account_help("last_used_world_name")), + ] = None, + # Addons options + startup_scripts: Annotated[ + tuple[Path, ...] | None, + Parameter( + group=AddonsGroup, + help=addons_help("enabled_startup_scripts"), + validator=cyclopts.validators.Path( + exists=False, file_okay=True, dir_okay=False, ext=("py",) + ), + ), + ] = None, + # Game WINE options + builtin_prefix_enabled: Annotated[ + bool | None, + Parameter(group=WineGroup, help=wine_help("builtin_prefix_enabled")), + ] = None, + user_wine_executable_path: Annotated[ + ResolvedExistingFile | None, + Parameter( + group=WineGroup, + help=wine_help("user_wine_executable_path"), + ), + ] = None, + user_prefix_path: Annotated[ + ResolvedExistingDirectory | None, + Parameter( + group=WineGroup, + help=wine_help("user_prefix_path"), + ), + ] = None, + wine_debug_level: Annotated[ + str | None, Parameter(group=WineGroup, help=wine_help("debug_level")) + ] = None, + ) -> int: + setup_application_logging() + config_manager.get_merged_game_config = partial( + _merge_game_config, + game_directory=game_directory, + locale=locale, + client_type=client_type, + high_res_enabled=high_res_enabled, + standard_game_launcher_filename=standard_game_launcher_filename, + patch_client_filename=patch_client_filename, + game_settings_directory=game_settings_directory, + newsfeed=newsfeed, + # Addons Section + enabled_startup_scripts=startup_scripts, + # WINE Section + builtin_prefix_enabled=builtin_prefix_enabled, + user_wine_executable_path=user_wine_executable_path, + user_prefix_path=user_prefix_path, + wine_debug_level=wine_debug_level, + ) + config_manager.get_merged_game_accounts_config = partial( + _merge_accounts_config, + username=username, + display_name=display_name, + last_used_world_name=last_used_world_name, ) - setup_wizard = SetupWizard(config_manager, game_selection_only=True) - if setup_wizard.exec() == QtWidgets.QDialog.DialogCode.Rejected: - # Close program if the user left the setup wizard without finishing - return - return await _start_ui(config_manager=config_manager, game_arg=game_arg) - - # Import has to be done here, because some code run when - # main_window.py imports requires the QApplication to exist. - from .main_window import MainWindow # noqa: PLC0415 - - game_id = _parse_game_arg(game_arg, config_manager) if game_arg else None - main_window = MainWindow(config_manager=config_manager, starting_game_id=game_id) - await main_window.run() + + return start_async_gui( + entry=partial(start_ui, config_manager=config_manager, game_id=_game_id), + ) + + @app.meta.meta.command(group=DevGroup) + def designer() -> int: + """Start pyside6-designer with the correct plugins and environment variables.""" + env = os.environ.copy() + env["PYTHONPATH"] = ( + f"{env['PYTHONPATH']}{os.pathsep}" if "PYTHONPATH" in env else "" + ) + env["PYTHONPATH"] += ( + f"{sysconfig.get_path('purelib')}{os.pathsep}{Path(inspect.getabsfile(onelauncher)).parent.parent}" + ) + env["PYSIDE_DESIGNER_PLUGINS"] = str( + Path(inspect.getabsfile(qtdesigner)).parent + ) + if nix_python := os.environ.get("NIX_PYTHON_ENV"): + # Trick pyside6-designer into setting the right LD_PRELOAD path for Python + # in Nix flake instead of the bare library name. + env["PYENV_ROOT"] = nix_python + subprocess.run( + "pyside6-designer", # noqa: S607 + env=env, + check=True, + ) + return 0 + + app.register_install_completion_command() + + @app.meta.meta.command(show=False) + def generate_shell_completion( + shell: Literal["zsh", "bash", "fish"] | None = None, + ) -> int: + print(app.generate_completion(shell=shell)) # noqa: T201 + return 0 + + return app.meta.meta diff --git a/src/onelauncher/config_manager.py b/src/onelauncher/config_manager.py index e0358cc1..af1348fa 100644 --- a/src/onelauncher/config_manager.py +++ b/src/onelauncher/config_manager.py @@ -22,10 +22,9 @@ from .program_config import GamesSortingMode, ProgramConfig from .resources import OneLauncherLocale, available_locales -PROGRAM_CONFIG_DEFAULT_PATH: Path = ( - platform_dirs.user_config_path / f"{__title__.lower()}.toml" -) -GAMES_DIR_DEFAULT_PATH: Path = platform_dirs.user_data_path / "games" +PROGRAM_CONFIG_DIR_DEFAULT: Path = platform_dirs.user_config_path +PROGRAM_CONFIG_DEFAULT_NAME = f"{__title__.lower()}.toml" +GAMES_DIR_DEFAULT: Path = platform_dirs.user_data_path / "games" def _structure_onelauncher_locale( @@ -85,6 +84,7 @@ def convert_to_toml( container.add(tomlkit.comment(metadata.help)) else: val = unprocessed_val + if isinstance(val, dict): if not val: continue @@ -319,17 +319,26 @@ class ConfigManagerNotSetupError(Exception): """Config manager hasn't been setup.""" -@attrs.define +@attrs.frozen(kw_only=True) +class NoValidGamesError(Exception): + msg: str = "There are no valid games registered." + + +@attrs.define(kw_only=True) class ConfigManager: """ Before use, configs must be verified with `verify_configs` method. """ - get_merged_program_config: Callable[[ProgramConfig], ProgramConfig] - get_merged_game_config: Callable[[GameConfig], GameConfig] - get_merged_game_accounts_config: Callable[[GameAccountsConfig], GameAccountsConfig] - program_config_path: Path = PROGRAM_CONFIG_DEFAULT_PATH - games_dir_path: Path = GAMES_DIR_DEFAULT_PATH + get_merged_program_config: Callable[[ProgramConfig], ProgramConfig] = ( + lambda config: config + ) + get_merged_game_config: Callable[[GameConfig], GameConfig] = lambda config: config + get_merged_game_accounts_config: Callable[ + [GameAccountsConfig], GameAccountsConfig + ] = lambda config: config + program_config_dir: Final[Path] = PROGRAM_CONFIG_DIR_DEFAULT + games_dir: Final[Path] = GAMES_DIR_DEFAULT GAME_CONFIG_FILE_NAME: Final[str] = attrs.field(default="config.toml", init=False) configs_are_verified: bool = attrs.field(default=False, init=False) @@ -343,8 +352,8 @@ class ConfigManager: ) def __attrs_post_init__(self) -> None: - self.program_config_path.parent.mkdir(parents=True, exist_ok=True) - self.games_dir_path.mkdir(parents=True, exist_ok=True) + self.program_config_dir.mkdir(parents=True, exist_ok=True) + self.games_dir.mkdir(parents=True, exist_ok=True) def verify_configs(self) -> None: """ @@ -373,14 +382,18 @@ def verify_configs(self) -> None: self.verified_game_config_ids.append(game_id) self.configs_are_verified = True + @property + def program_config_path(self) -> Path: + return self.program_config_dir / PROGRAM_CONFIG_DEFAULT_NAME + def get_game_config_dir(self, game_id: GameConfigID) -> Path: - return self.games_dir_path / game_id + return self.games_dir / str(game_id) def get_game_config_path(self, game_id: GameConfigID) -> Path: return self.get_game_config_dir(game_id) / self.GAME_CONFIG_FILE_NAME def get_game_id_from_config_path(self, config_path: Path) -> GameConfigID: - return config_path.parent.name + return GameConfigID(config_path.parent.name) def get_game_accounts_config_path(self, game_id: GameConfigID) -> Path: return self.get_game_config_dir(game_id) / "accounts.toml" @@ -445,9 +458,7 @@ def get_game_config_ids(self) -> tuple[GameConfigID, ...]: def _get_game_config_ids(self) -> tuple[GameConfigID, ...]: return tuple( self.get_game_id_from_config_path(config_path=config_file) - for config_file in self.games_dir_path.glob( - f"*/{self.GAME_CONFIG_FILE_NAME}" - ) + for config_file in self.games_dir.glob(f"*/{self.GAME_CONFIG_FILE_NAME}") ) def get_games_by_game_type(self, game_type: GameType) -> tuple[GameConfigID, ...]: @@ -531,6 +542,21 @@ def get_games_sorted_alphabetically( sorted(game_ids, key=lambda game_id: self.get_game_config(game_id).name) ) + def get_initial_game(self) -> GameConfigID: + """ + Get which game has the highest priority/should be presented first. + + Raises: + NoValidGamesError: No valid games are registered. + """ + if not (games_by_last_played := self.get_games_sorted_by_last_played()): + raise NoValidGamesError() + return ( + games_by_last_played[0] + if self.get_game_config(games_by_last_played[0]).last_played is not None + else self.get_games_sorted(self.get_program_config().games_sorting_mode)[0] + ) + def get_game_config(self, game_id: GameConfigID) -> GameConfig: """ Get merged game config object. diff --git a/src/onelauncher/game_config.py b/src/onelauncher/game_config.py index bf56dbdd..acb3b385 100644 --- a/src/onelauncher/game_config.py +++ b/src/onelauncher/game_config.py @@ -1,7 +1,6 @@ from collections.abc import Iterable from datetime import datetime from enum import StrEnum -from typing import TypeAlias from uuid import uuid4 import attrs @@ -28,6 +27,17 @@ class GameType(StrEnum): DDO = "DDO" +class GameConfigID: + def __init__(self, game_id: str, /) -> None: + if not game_id: + raise ValueError("GameConfigID cannot be empty") + self._value: str = game_id + + @override + def __str__(self) -> str: + return self._value + + @attrs.frozen(kw_only=True) class GameConfig(Config): addons: AddonsConfigSection = config_field( @@ -90,9 +100,6 @@ def get_config_file_description() -> str: return f"A game config file for {__title__}" -GameConfigID: TypeAlias = str - - def generate_game_name( game_config: GameConfig, existing_game_names: Iterable[str] = () ) -> str: @@ -114,7 +121,7 @@ def generate_game_name( def generate_game_config_id(game_config: GameConfig) -> GameConfigID: - return ( + return GameConfigID( f"{uuid4()}-{game_config.game_type}" f"{'-Preview' if game_config.is_preview_client else ''}" ) diff --git a/src/onelauncher/main.py b/src/onelauncher/main.py new file mode 100644 index 00000000..6b15088d --- /dev/null +++ b/src/onelauncher/main.py @@ -0,0 +1,111 @@ +import logging +import traceback + +from PySide6 import QtWidgets + +from .__about__ import __title__ +from .config_manager import ( + ConfigFileError, + ConfigManager, + NoValidGamesError, + WrongConfigVersionError, +) +from .game_config import GameConfigID +from .main_window import MainWindow +from .qtapp import get_qapp +from .setup_wizard import SetupWizard +from .ui.error_message_uic import Ui_errorDialog + +logger = logging.getLogger(__name__) + + +def show_invalid_config_dialog( + error: ConfigFileError, backup_available: bool = False +) -> bool | None: + """ + Returns: + None: When `backup_available` is `False`. + bool: Wether the user wants to load the backup. + """ + _ = get_qapp() + dialog = QtWidgets.QDialog() + ui = Ui_errorDialog() + ui.setupUi(dialog) + ui.textLabel.setText(error.msg) + ui.detailsTextEdit.setPlainText(traceback.format_exc()) + + if backup_available: + ui.buttonBox.addButton("Load Backup", ui.buttonBox.ButtonRole.AcceptRole) + return dialog.exec() == dialog.DialogCode.Accepted + else: + dialog.exec() + return None + + +def verify_configs(config_manager: ConfigManager) -> bool: + """ + Verify configs, notify user of problems, allow loading backups, and return whether + the configs are valid. + + Returns: + bool: Wether the configs after valid after all user prompting/potential backup + loading. + """ + try: + config_manager.verify_configs() + except ConfigFileError as e: + if ( + isinstance(e, WrongConfigVersionError) + and e.config_file_version < e.config_class.get_config_version() + ): + # This is where code to handle config migrations from 2.0+ config files should go. + raise e + logger.exception("") + + config_backup_path = config_manager.get_config_backup_path( + config_path=e.config_file_path + ) + # Replace config with backup, if the user clicks the "Load Backup" button. + if config_backup_path.exists(): + if show_invalid_config_dialog(error=e, backup_available=True): + e.config_file_path.unlink() + config_backup_path.rename(e.config_file_path) + return verify_configs(config_manager=config_manager) + else: + show_invalid_config_dialog(error=e) + return False + + return True + + +async def start_ui( + config_manager: ConfigManager, game_id: GameConfigID | None +) -> None: + # Run setup wizard. + if not config_manager.program_config_path.exists(): + logger.info("No program config found. Starting setup wizard.") + setup_wizard = SetupWizard(config_manager) + if setup_wizard.exec() == QtWidgets.QDialog.DialogCode.Rejected: + # Close program if the user left the setup wizard without finishing. + return + return await start_ui(config_manager=config_manager, game_id=game_id) + + try: + initial_game_id = config_manager.get_initial_game() + # Run the games selection portion of the setup wizard. + except NoValidGamesError: + QtWidgets.QMessageBox.information( + None, + "No Games Found", + f"No games have been registered with {__title__}.\n Opening games management wizard.", + ) + setup_wizard = SetupWizard(config_manager, game_selection_only=True) + if setup_wizard.exec() == QtWidgets.QDialog.DialogCode.Rejected: + # Close program if the user left the setup wizard without finishing. + return + return await start_ui(config_manager=config_manager, game_id=game_id) + + main_window = MainWindow( + config_manager=config_manager, game_id=game_id or initial_game_id + ) + await main_window.run() diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index d77f131b..e9cb6378 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -46,7 +46,7 @@ from onelauncher.ui.custom_widgets import FramelessQMainWindowWithStylePreview from . import __about__, addon_manager -from .config_manager import ConfigManager +from .config_manager import ConfigManager, NoValidGamesError from .game_account_config import GameAccountConfig from .game_config import GameConfigID, GameType from .game_launcher_local_config import ( @@ -89,28 +89,18 @@ class MainWindow(FramelessQMainWindowWithStylePreview): def __init__( self, config_manager: ConfigManager, - starting_game_id: GameConfigID | None = None, + game_id: GameConfigID, ) -> None: super().__init__(None) self.titleBar.hide() self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, on=True) self.config_manager = config_manager - self.game_id: GameConfigID = starting_game_id or self.get_starting_game_id() + self.game_id: GameConfigID = game_id self.network_setup_nursery: trio.Nursery | None = None self.addon_manager_window: addon_manager.AddonManagerWindow | None = None self.game_launcher_config: GameLauncherConfig | None = None - def get_starting_game_id(self) -> GameConfigID: - last_played = self.config_manager.get_games_sorted_by_last_played()[0] - return ( - last_played - if self.config_manager.get_game_config(last_played).last_played is not None - else self.config_manager.get_games_sorted( - self.config_manager.get_program_config().games_sorting_mode - )[0] - ) - def addon_manager_error_log(self, record: logging.LogRecord) -> None: self.ui.txtStatus.append(log_record_to_rich_text(record)) self.raise_() @@ -801,7 +791,11 @@ async def InitialSetup(self) -> None: # Handle when current game has been removed. if self.game_id not in self.config_manager.get_game_config_ids(): - self.game_id = self.get_starting_game_id() + try: + self.game_id = self.config_manager.get_initial_game() + except NoValidGamesError as e: + logger.exception(e.msg) + return await self.InitialSetup() return diff --git a/src/onelauncher/program_config.py b/src/onelauncher/program_config.py index e9f1cff3..1035ab81 100644 --- a/src/onelauncher/program_config.py +++ b/src/onelauncher/program_config.py @@ -28,7 +28,7 @@ class GamesSortingMode(Enum): class ProgramConfig(Config): default_locale: OneLauncherLocale = config_field( default=get_default_locale(), - help="The default language for games and UI.", + help="Default language for games and UI", ) always_use_default_locale_for_ui: bool = config_field( default=False, help="Use default language for UI regardless of game language" diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 8b2330e5..77d0309f 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -108,7 +108,7 @@ def setup_ui(self) -> None: ) ) ) - self.ui.gameConfigIDLineEdit.setText(self.game_id) + self.ui.gameConfigIDLineEdit.setText(str(self.game_id)) self.ui.gameDescriptionLineEdit.setText(game_config.description) self.ui.gameDirLineEdit.setText(str(game_config.game_directory)) self.ui.browseGameConfigDirButton.clicked.connect( diff --git a/tests/onelauncher/test_cli.py b/tests/onelauncher/test_cli.py new file mode 100644 index 00000000..f25c40c8 --- /dev/null +++ b/tests/onelauncher/test_cli.py @@ -0,0 +1,182 @@ +from pathlib import Path +from shutil import rmtree + +import pytest +from PySide6 import QtWidgets +from pytest_mock import MockerFixture + +import cyclopts +from onelauncher import cli, main +from onelauncher.addons.config import AddonsConfigSection +from onelauncher.config_manager import ( + PROGRAM_CONFIG_DEFAULT_NAME, + ConfigFileError, + ConfigManager, +) +from onelauncher.game_config import GameConfig, GameType, generate_game_config_id +from onelauncher.main_window import MainWindow +from onelauncher.qtapp import get_qapp +from onelauncher.setup_wizard import SetupWizard +from onelauncher.utilities import CaseInsensitiveAbsolutePath +from onelauncher.wine.config import WineConfigSection + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + config_dir = tmp_path / "config" + config_dir.mkdir() + return config_dir + + +@pytest.fixture +def games_dir(tmp_path: Path) -> Path: + games_dir = tmp_path / "games" + games_dir.mkdir() + return games_dir + + +@pytest.fixture +def config_manager(config_dir: Path, games_dir: Path, tmp_path: Path) -> ConfigManager: + config_manager = ConfigManager(program_config_dir=config_dir, games_dir=games_dir) + config_manager.verify_configs() + + config_manager.update_program_config_file(config_manager.read_program_config_file()) + + mock_game_dir = CaseInsensitiveAbsolutePath(tmp_path / "lotro_game_dir") + mock_game_dir.mkdir() + game_config = GameConfig( + addons=AddonsConfigSection(), + wine=WineConfigSection(), + game_type=GameType.LOTRO, + is_preview_client=False, + game_directory=mock_game_dir, + ) + config_manager.update_game_config_file( + game_id=generate_game_config_id(game_config), config=game_config + ) + + return config_manager + + +@pytest.fixture +def app( + monkeypatch: pytest.MonkeyPatch, config_dir: Path, games_dir: Path +) -> cyclopts.App: + monkeypatch.setenv(name="ONELAUNCHER_CONFIG_DIRECTORY", value=str(config_dir)) + monkeypatch.setenv(name="ONELAUNCHER_GAMES_DIRECTORY", value=str(games_dir)) + app = cli.get_app() + # The return value will be what would have been the exit code. + app.result_action = "return_value" + return app + + +async def test_normal( + config_manager: ConfigManager, app: cyclopts.App, mocker: MockerFixture +) -> None: + async_mock = mocker.patch.object(cli, "start_async_gui") + async_mock.return_value = 0 + + assert app([]) == 0 + async_mock.assert_called_once() + + get_qapp() + main_window_mock = mocker.patch.object(MainWindow, "run") + + await async_mock.call_args.kwargs["entry"]() + main_window_mock.assert_called_once() + + +async def test_no_config(app: cyclopts.App, mocker: MockerFixture) -> None: + async_mock = mocker.patch.object(cli, "start_async_gui") + async_mock.return_value = 0 + + assert app([]) == 0 + async_mock.assert_called_once() + + get_qapp() + mock = mocker.patch.object(SetupWizard, "exec") + mock.return_value = QtWidgets.QDialog.DialogCode.Rejected + + await async_mock.call_args.kwargs["entry"]() + mock.assert_called_once() + + +async def test_no_games( + config_manager: ConfigManager, app: cyclopts.App, mocker: MockerFixture +) -> None: + rmtree(config_manager.games_dir) + + async_mock = mocker.patch.object(cli, "start_async_gui") + async_mock.return_value = 0 + + assert app([]) == 0 + async_mock.assert_called_once() + + get_qapp() + mocker.patch.object(QtWidgets.QMessageBox, "information") + spy = mocker.spy(main, "SetupWizard") + mock = mocker.patch.object(SetupWizard, "exec") + mock.return_value = QtWidgets.QDialog.DialogCode.Rejected + + await async_mock.call_args.kwargs["entry"]() + spy.assert_called_once() + assert spy.call_args.kwargs["game_selection_only"] is True + mock.assert_called_once() + + +def test_invalid_program_config( + config_dir: Path, app: cyclopts.App, mocker: MockerFixture +) -> None: + (config_dir / PROGRAM_CONFIG_DEFAULT_NAME).write_text("INVALID") + + mock = mocker.patch.object(main, "show_invalid_config_dialog") + mock.return_value = None + + assert app([]) == 1 + mock.assert_called_once() + assert isinstance(mock.call_args.kwargs["error"], ConfigFileError) + assert not mock.call_args.kwargs.get("backup_available") + + +def test_invalid_program_config_with_backup( + config_dir: Path, app: cyclopts.App, mocker: MockerFixture +) -> None: + (config_dir / PROGRAM_CONFIG_DEFAULT_NAME).write_text("INVALID") + (config_dir / f"{PROGRAM_CONFIG_DEFAULT_NAME}.backup").touch() + + mock = mocker.patch.object(main, "show_invalid_config_dialog") + mock.return_value = False + + assert app([]) == 1 + mock.assert_called_once() + assert isinstance(mock.call_args.kwargs["error"], ConfigFileError) + assert mock.call_args.kwargs["backup_available"] is True + + +async def test_invalid_program_config_load_backup( + app: cyclopts.App, + mocker: MockerFixture, + config_manager: ConfigManager, +) -> None: + backup_program_config = config_manager.get_config_backup_path( + config_manager.program_config_path + ) + config_manager.program_config_path.rename(backup_program_config) + config_manager.program_config_path.write_text("INVALID") + + mock = mocker.patch.object(main, "show_invalid_config_dialog") + mock.return_value = True + + async_mock = mocker.patch.object(cli, "start_async_gui") + async_mock.return_value = 0 + + assert app([]) == 0 + async_mock.assert_called_once() + + get_qapp() + main_window_mock = mocker.patch.object(MainWindow, "run") + + await async_mock.call_args.kwargs["entry"]() + main_window_mock.assert_called_once() + + assert not backup_program_config.exists() diff --git a/uv.lock b/uv.lock index ced1ac72..d81b2794 100644 --- a/uv.lock +++ b/uv.lock @@ -136,18 +136,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -204,6 +192,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, ] +[[package]] +name = "cyclopts" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/0f/fe026df2ab8301e30a2b0bd425ff1462ad858fd4f991c1ac0389c2059c24/cyclopts-4.3.0.tar.gz", hash = "sha256:e95179cd0a959ce250ecfb2f0262a5996a92c1f9467bccad2f3d829e6833cef5", size = 151411, upload-time = "2025-11-25T02:59:33.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/e8/77a231ae531cf38765b75ddf27dae28bb5f70b41d8bb4f15ce1650e93f57/cyclopts-4.3.0-py3-none-any.whl", hash = "sha256:91a30b69faf128ada7cfeaefd7d9649dc222e8b2a8697f1fc99e4ee7b7ca44f3", size = 187184, upload-time = "2025-11-25T02:59:32.21Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -213,6 +216,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, +] + [[package]] name = "elementpath" version = "5.0.4" @@ -492,6 +513,7 @@ dependencies = [ { name = "cachetools" }, { name = "cattrs", extra = ["tomlkit"] }, { name = "cryptography" }, + { name = "cyclopts" }, { name = "defusedxml" }, { name = "feedparser" }, { name = "httpx" }, @@ -503,7 +525,6 @@ dependencies = [ { name = "qtawesome" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, { name = "trio" }, - { name = "typer" }, { name = "xmlschema" }, { name = "zeep" }, ] @@ -519,7 +540,9 @@ dev = [ { name = "nuitka" }, { name = "pyside6-essentials" }, { name = "pytest" }, + { name = "pytest-mock" }, { name = "pytest-randomly" }, + { name = "pytest-trio" }, { name = "ruff" }, { name = "types-cachetools" }, ] @@ -532,7 +555,9 @@ lint = [ test = [ { name = "mypy" }, { name = "pytest" }, + { name = "pytest-mock" }, { name = "pytest-randomly" }, + { name = "pytest-trio" }, ] [package.metadata] @@ -543,6 +568,7 @@ requires-dist = [ { name = "cachetools", specifier = ">=5.4.0" }, { name = "cattrs", extras = ["tomlkit"], specifier = ">=23.2.3" }, { name = "cryptography", specifier = ">=43.0.0" }, + { name = "cyclopts", specifier = ">=4.3.0" }, { name = "defusedxml", specifier = ">=0.7.1" }, { name = "feedparser", specifier = ">=6.0.11" }, { name = "httpx", specifier = ">=0.27.0" }, @@ -554,7 +580,6 @@ requires-dist = [ { name = "qtawesome", specifier = ">=1.3.1" }, { name = "secretstorage", marker = "sys_platform == 'linux'", specifier = ">=3.3.3" }, { name = "trio", specifier = ">=0.26.2" }, - { name = "typer", specifier = ">=0.12.3" }, { name = "xmlschema", specifier = ">=3.3.2" }, { name = "zeep", git = "https://github.com/JuneStepp/python-zeep.git" }, ] @@ -567,24 +592,28 @@ build = [ dev = [ { name = "marko", specifier = ">=2.1.2" }, { name = "mypy" }, - { name = "mypy", specifier = ">=1.11.1" }, + { name = "mypy", specifier = ">=1.18.2" }, { name = "nuitka", specifier = ">=2.4.8" }, - { name = "pyside6-essentials", specifier = ">=6.9.0" }, + { name = "pyside6-essentials", specifier = ">=6.10.0" }, { name = "pytest", specifier = ">=8.3.2" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-randomly", specifier = ">=3.15.0" }, - { name = "ruff", specifier = ">=0.11.11" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, + { name = "ruff", specifier = ">=0.14.1" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, ] lint = [ - { name = "mypy", specifier = ">=1.11.1" }, - { name = "pyside6-essentials", specifier = ">=6.9.0" }, - { name = "ruff", specifier = ">=0.11.11" }, + { name = "mypy", specifier = ">=1.18.2" }, + { name = "pyside6-essentials", specifier = ">=6.10.0" }, + { name = "ruff", specifier = ">=0.14.1" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, ] test = [ { name = "mypy" }, { name = "pytest", specifier = ">=8.3.2" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-randomly", specifier = ">=3.15.0" }, + { name = "pytest-trio", specifier = ">=0.8.0" }, ] [[package]] @@ -673,7 +702,7 @@ wheels = [ [[package]] name = "pyobjc" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -838,115 +867,115 @@ dependencies = [ { name = "pyobjc-framework-vision", marker = "platform_release >= '17.0'" }, { name = "pyobjc-framework-webkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/0f/0b21447c9461905022aab2f19626e94a0b00eee9c6d3593a5ab425f7a42e/pyobjc-12.0.tar.gz", hash = "sha256:ce6b7c68889722248250d1b4daac28272100634e3a9826affdbd6f36a0dc52b2", size = 11236, upload-time = "2025-10-21T08:25:05.018Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/06/d77639ba166cc09aed2d32ae204811b47bc5d40e035cdc9bff7fff72ec5f/pyobjc-12.1.tar.gz", hash = "sha256:686d6db3eb3182fac9846b8ce3eedf4c7d2680b21b8b8d6e6df054a17e92a12d", size = 11345, upload-time = "2025-11-14T10:07:28.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/36/f5335452694fb4bc0dd69affe516886abde64ad43ed88d9b104d822a29de/pyobjc-12.0-py3-none-any.whl", hash = "sha256:cc0004c8e615d4b99f4910804477b322d951d472d5ee20bfef8f390ea734d038", size = 4204, upload-time = "2025-10-21T07:49:12.453Z" }, + { url = "https://files.pythonhosted.org/packages/ef/00/1085de7b73abf37ec27ad59f7a1d7a406e6e6da45720bced2e198fdf1ddf/pyobjc-12.1-py3-none-any.whl", hash = "sha256:6f8c36cf87b1159d2ca1aa387ffc3efcd51cc3da13ef47c65f45e6d9fbccc729", size = 4226, upload-time = "2025-11-14T09:30:25.185Z" }, ] [[package]] name = "pyobjc-core" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/dc/6d63019133e39e2b299dfbab786e64997fff0f145c45a417e1dd51faaf3f/pyobjc_core-12.0.tar.gz", hash = "sha256:7e05c805a776149a937b61b892a0459895d32d9002bedc95ce2be31ef1e37a29", size = 991669, upload-time = "2025-10-21T08:26:07.496Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/c1/c50e312d32644429d8a9bb3a342aeeb772fba85f9573e7681ca458124a8f/pyobjc_core-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dd4962aceb0f9a0ee510e11ced449323db85e42664ac9ade53ad1cc2394dc248", size = 673921, upload-time = "2025-10-21T07:50:09.974Z" }, + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, ] [[package]] name = "pyobjc-framework-accessibility" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/77/28cf2885e6964932773456114ba1012e2a5c60f31582a2dc4980aa6018a9/pyobjc_framework_accessibility-12.0.tar.gz", hash = "sha256:a7794887330d4e50d41af72633d08aa41a9e946a80c49b4ede4a2f7936751c46", size = 30002, upload-time = "2025-10-21T08:26:11.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/87/8ca40428d05a668fecc638f2f47dba86054dbdc35351d247f039749de955/pyobjc_framework_accessibility-12.1.tar.gz", hash = "sha256:5ff362c3425edc242d49deec11f5f3e26e565cefb6a2872eda59ab7362149772", size = 29800, upload-time = "2025-11-14T10:08:31.949Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/c6/dec3b6cf566ca01c5ba7c812dafa48b1c29bcfb19960210e53892e8ff4c0/pyobjc_framework_accessibility-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:712200ae59303ea76a00ecb4ecb4ee59c97e4d1fc66fe1555d053f3b320f3915", size = 11270, upload-time = "2025-10-21T07:53:30.336Z" }, + { url = "https://files.pythonhosted.org/packages/76/00/182c57584ad8e5946a82dacdc83c9791567e10bffdea1fe92272b3fdec14/pyobjc_framework_accessibility-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5e29dac0ce8327cd5a8b9a5a8bd8aa83e4070018b93699e97ac0c3af09b42a9a", size = 11301, upload-time = "2025-11-14T09:35:28.678Z" }, ] [[package]] name = "pyobjc-framework-accounts" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/77/da53be3992e793a857fb07fe3dfc3a595b9c2365f00451578d2843413d30/pyobjc_framework_accounts-12.0.tar.gz", hash = "sha256:48fa0d270208655fa47b89452fa3ef5eadadf61ecf5935b83f22bcb3c28feabe", size = 15288, upload-time = "2025-10-21T08:26:13.567Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/10/f6fe336c7624d6753c1f6edac102310ce4434d49b548c479e8e6420d4024/pyobjc_framework_accounts-12.1.tar.gz", hash = "sha256:76d62c5e7b831eb8f4c9ca6abaf79d9ed961dfffe24d89a041fb1de97fe56a3e", size = 15202, upload-time = "2025-11-14T10:08:33.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/b3/e18aa7763b1de9a116862a022f21d35fbedeb5e8d4aff9633446d3088bef/pyobjc_framework_accounts-12.0-py2.py3-none-any.whl", hash = "sha256:9a12dcb35c4367ab846abcd3a529778ba527155b31249380a8eb360baacdcb05", size = 5116, upload-time = "2025-10-21T07:53:41.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/70/5f9214250f92fbe2e07f35778875d2771d612f313af2a0e4bacba80af28e/pyobjc_framework_accounts-12.1-py2.py3-none-any.whl", hash = "sha256:e1544ad11a2f889a7aaed649188d0e76d58595a27eec07ca663847a7adb21ae5", size = 5104, upload-time = "2025-11-14T09:35:40.246Z" }, ] [[package]] name = "pyobjc-framework-addressbook" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/9e/fed3073b5e712d3ed14d27410f03e84c1ea164c560ac7b597b1e6fc8dea8/pyobjc_framework_addressbook-12.0.tar.gz", hash = "sha256:1004b7d8e610748c9ce61aeab766319c2632d1e314838e95eb10f0dd6a64f3d8", size = 44733, upload-time = "2025-10-21T08:26:17.23Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/28/0404af2a1c6fa8fd266df26fb6196a8f3fb500d6fe3dab94701949247bea/pyobjc_framework_addressbook-12.1.tar.gz", hash = "sha256:c48b740cf981103cef1743d0804a226d86481fcb839bd84b80e9a586187e8000", size = 44359, upload-time = "2025-11-14T10:08:37.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/15/e0b1ed13a66676152490f220bd325894703348a2dd0e9e349072e8be621e/pyobjc_framework_addressbook-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:773908f0c7c126079ca9afff6679487a62c385511250d43d97508a1f4213621a", size = 12887, upload-time = "2025-10-21T07:53:46.15Z" }, + { url = "https://files.pythonhosted.org/packages/9f/5a/2ecaa94e5f56c6631f0820ec4209f8075c1b7561fe37495e2d024de1c8df/pyobjc_framework_addressbook-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:681755ada6c95bd4a096bc2b9f9c24661ffe6bff19a96963ee3fad34f3d61d2b", size = 12879, upload-time = "2025-11-14T09:35:45.21Z" }, ] [[package]] name = "pyobjc-framework-adservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/63/98e08ce5ba933b104fe73126c1050fc2a4c02ebd654f1ecba272d98892d2/pyobjc_framework_adservices-12.0.tar.gz", hash = "sha256:e58ec0c617f9967d1c1b717fb291ce675555f7ece0b3999d2e8b74d2a49c161e", size = 11834, upload-time = "2025-10-21T08:26:19.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/04/1c3d3e0a1ac981664f30b33407dcdf8956046ecde6abc88832cf2aa535f4/pyobjc_framework_adservices-12.1.tar.gz", hash = "sha256:7a31fc8d5c6fd58f012db87c89ba581361fc905114bfb912e0a3a87475c02183", size = 11793, upload-time = "2025-11-14T10:08:39.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/26/ecad8d077c3ce9662fdd57c6c0d1d6ba89b8bd96bcfe4ed28f6c214365f8/pyobjc_framework_adservices-12.0-py2.py3-none-any.whl", hash = "sha256:bf6f6992a00295e936a0cde486f20cf0747b0341d317ead3a353c6c7d327a2e2", size = 3505, upload-time = "2025-10-21T07:53:57.987Z" }, + { url = "https://files.pythonhosted.org/packages/ad/13/f7796469b25f50750299c4b0e95dc2f75c7c7fc4c93ef2c644f947f10529/pyobjc_framework_adservices-12.1-py2.py3-none-any.whl", hash = "sha256:9ca3c55e35b2abb3149a0bce5de9a1f7e8ee4f8642036910ca8586ab2e161538", size = 3492, upload-time = "2025-11-14T09:35:57.344Z" }, ] [[package]] name = "pyobjc-framework-adsupport" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b0/e2/0deac6d431ba4b319784b8b25e6bd060385556d50ff1b76aab7b43d54972/pyobjc_framework_adsupport-12.0.tar.gz", hash = "sha256:accaaa66739260b5420aa085cfb1dd1fc4b0b52c59076124b9355bd60d2c129c", size = 11714, upload-time = "2025-10-21T08:26:21.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/77/f26a2e9994d4df32e9b3680c8014e350b0f1c78d7673b3eba9de2e04816f/pyobjc_framework_adsupport-12.1.tar.gz", hash = "sha256:9a68480e76de567c339dca29a8c739d6d7b5cad30e1cd585ff6e49ec2fc283dd", size = 11645, upload-time = "2025-11-14T10:08:41.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/bb/82529e38c1f83f08a4f84241e2935ad3c545142a8e7d65d9c5461e6ca56e/pyobjc_framework_adsupport-12.0-py2.py3-none-any.whl", hash = "sha256:649fb4114cf1f16bb9c402c360a39eb0ea84e72e49cd6db5451a2806bbc05b24", size = 3412, upload-time = "2025-10-21T07:53:59.452Z" }, + { url = "https://files.pythonhosted.org/packages/cd/1a/3e90d5a09953bde7b60946cd09cca1411aed05dea855cb88cb9e944c7006/pyobjc_framework_adsupport-12.1-py2.py3-none-any.whl", hash = "sha256:97dcd8799dd61f047bb2eb788bbde81f86e95241b5e5173a3a61cfc05b5598b1", size = 3401, upload-time = "2025-11-14T09:35:59.039Z" }, ] [[package]] name = "pyobjc-framework-applescriptkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/ee/9f861171c5dbc1f132e884415e573038372fb1af83c1d23fdaeae20ab4e3/pyobjc_framework_applescriptkit-12.0.tar.gz", hash = "sha256:69f57f2f6dd72bdb83f69e33839438caf804302fb177e00136cd49a172e6cc32", size = 11504, upload-time = "2025-10-21T08:26:22.979Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/f1/e0c07b2a9eb98f1a2050f153d287a52a92f873eeddb41b74c52c144d8767/pyobjc_framework_applescriptkit-12.1.tar.gz", hash = "sha256:cb09f88cf0ad9753dedc02720065818f854b50e33eb4194f0ea34de6d7a3eb33", size = 11451, upload-time = "2025-11-14T10:08:43.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/84/595a8acb19958de210f04c5d79bff30337d04ca00c20374db4acbfe5c83d/pyobjc_framework_applescriptkit-12.0-py2.py3-none-any.whl", hash = "sha256:940e10bc281a0155a01f817275b11c6819ae773891847c8c90403d27aa6efb5d", size = 4363, upload-time = "2025-10-21T07:54:00.974Z" }, + { url = "https://files.pythonhosted.org/packages/3b/70/6c399c6ebc37a4e48acf63967e0a916878aedfe420531f6d739215184c0c/pyobjc_framework_applescriptkit-12.1-py2.py3-none-any.whl", hash = "sha256:b955fc017b524027f635d92a8a45a5fd9fbae898f3e03de16ecd94aa4c4db987", size = 4352, upload-time = "2025-11-14T09:36:00.705Z" }, ] [[package]] name = "pyobjc-framework-applescriptobjc" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/81/28f123566793ff9037a218a393272a569020ebd228f343dccb6920855355/pyobjc_framework_applescriptobjc-12.0.tar.gz", hash = "sha256:5d89b060fa960bc34b5a505cd5fbbd3625c8035d7246ff0315a00acb205e8a92", size = 11624, upload-time = "2025-10-21T08:26:24.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/4b/e4d1592207cbe17355e01828bdd11dd58f31356108f6a49f5e0484a5df50/pyobjc_framework_applescriptobjc-12.1.tar.gz", hash = "sha256:dce080ed07409b0dda2fee75d559bd312ea1ef0243a4338606440f282a6a0f5f", size = 11588, upload-time = "2025-11-14T10:08:45.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/e7/f53cb5ade63db949ecde23bdcc20867453f24d6faf29b9fa2a2276ab252c/pyobjc_framework_applescriptobjc-12.0-py2.py3-none-any.whl", hash = "sha256:6b4926a29ea2cefea482ff28152dda0e05f2f8ec6d9f84d97a6d19bb872f824b", size = 4461, upload-time = "2025-10-21T07:54:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5f/9ce6706399706930eb29c5308037109c30cfb36f943a6df66fdf38cc842a/pyobjc_framework_applescriptobjc-12.1-py2.py3-none-any.whl", hash = "sha256:79068f982cc22471712ce808c0a8fd5deea11258fc8d8c61968a84b1962a3d10", size = 4454, upload-time = "2025-11-14T09:36:02.276Z" }, ] [[package]] name = "pyobjc-framework-applicationservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -954,92 +983,92 @@ dependencies = [ { name = "pyobjc-framework-coretext" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/79/0b7a00bcc7561c816281382c933a46aa7a90acca48b942054b7d32d0caf7/pyobjc_framework_applicationservices-12.0.tar.gz", hash = "sha256:eabbf6c57573158714aa656e5d0112330a87692db336aae7e94e216db89e93be", size = 103595, upload-time = "2025-10-21T08:26:32.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ba/62e7bfce26b1f742a4b6f204a77d807e14766ceb3c6b9f702be6de3f9b38/pyobjc_framework_applicationservices-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d9684f53b42d534fd67a23a9958c53bf6c738e7b478fa3a87263865a013f287", size = 32799, upload-time = "2025-10-21T07:54:08.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/86/d07eff705ff909a0ffa96d14fc14026e9fc9dd716233648c53dfd5056b8e/pyobjc_framework_applicationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bdddd492eeac6d14ff2f5bd342aba29e30dffa72a2d358c08444da22129890e2", size = 32784, upload-time = "2025-11-14T09:36:08.755Z" }, ] [[package]] name = "pyobjc-framework-apptrackingtransparency" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/bb/7cde677be892d94ca07b82612704861899710865e650530c5a0fed91fbea/pyobjc_framework_apptrackingtransparency-12.0.tar.gz", hash = "sha256:22bd689ab7a6b457ece8bf86cad615af10c2f36203ea4307273f74e4e372cdf4", size = 12468, upload-time = "2025-10-21T08:26:34.845Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/de/f24348982ecab0cb13067c348fc5fbc882c60d704ca290bada9a2b3e594b/pyobjc_framework_apptrackingtransparency-12.1.tar.gz", hash = "sha256:e25bf4e4dfa2d929993ee8e852b28fdf332fa6cde0a33328fdc3b2f502fa50ec", size = 12407, upload-time = "2025-11-14T10:08:54.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/42/1fd41fd755fb686f2842a51610351904e1414448fe306fa3ff2d9a72e8dd/pyobjc_framework_apptrackingtransparency-12.0-py2.py3-none-any.whl", hash = "sha256:543d9eb6ce6397930b8eb6e7162e6592f708f251f2fd6e9307bfa965daf10f7d", size = 3891, upload-time = "2025-10-21T07:54:26.96Z" }, + { url = "https://files.pythonhosted.org/packages/19/b2/90120b93ecfb099b6af21696c26356ad0f2182bdef72b6cba28aa6472ca6/pyobjc_framework_apptrackingtransparency-12.1-py2.py3-none-any.whl", hash = "sha256:23a98ade55495f2f992ecf62c3cbd8f648cbd68ba5539c3f795bf66de82e37ca", size = 3879, upload-time = "2025-11-14T09:36:26.425Z" }, ] [[package]] name = "pyobjc-framework-arkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/32/edd3198e33e9ad0e5d47cb228c1346a05a6523d242af1f9dd74ec2ef3c8b/pyobjc_framework_arkit-12.0.tar.gz", hash = "sha256:29c34f5db22f084cf1ae285562a5ad6522f9166d725eb55df987021f8d02e257", size = 35830, upload-time = "2025-10-21T08:26:37.852Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/8b/843fe08e696bca8e7fc129344965ab6280f8336f64f01ba0a8862d219c3f/pyobjc_framework_arkit-12.1.tar.gz", hash = "sha256:0c5c6b702926179700b68ba29b8247464c3b609fd002a07a3308e72cfa953adf", size = 35814, upload-time = "2025-11-14T10:08:57.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/23/43d3032baebebb2d35055c56a3c42f31a68fb84dc80443e565644ac213c0/pyobjc_framework_arkit-12.0-py2.py3-none-any.whl", hash = "sha256:90997c4e205bb2023886f59de635d1d9ded139d0add8d9941c8ebb69d5a92284", size = 8310, upload-time = "2025-10-21T07:54:28.73Z" }, + { url = "https://files.pythonhosted.org/packages/21/1e/64c55b409243b3eb9abc7a99e7b27ad4e16b9e74bc4b507fb7e7b81fd41a/pyobjc_framework_arkit-12.1-py2.py3-none-any.whl", hash = "sha256:f6d39e28d858ee03f052d6780a552247e682204382dbc090f1d3192fa1b21493", size = 8302, upload-time = "2025-11-14T09:36:28.127Z" }, ] [[package]] name = "pyobjc-framework-audiovideobridging" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/16/92f2ecb7ad7329ff25b44b7cc1d7bd6dbf56bc4511c99cd1b157d4f4941f/pyobjc_framework_audiovideobridging-12.0.tar.gz", hash = "sha256:b38b564b4b2f5edbba8bfde8e0c26eef3a7a654faf0ad0a1b2a1ea6219371772", size = 38916, upload-time = "2025-10-21T08:26:41.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/51/f81581e7a3c5cb6c9254c6f1e1ee1d614930493761dec491b5b0d49544b9/pyobjc_framework_audiovideobridging-12.1.tar.gz", hash = "sha256:6230ace6bec1f38e8a727c35d054a7be54e039b3053f98e6dd8d08d6baee2625", size = 38457, upload-time = "2025-11-14T10:09:01.122Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/78/172a079cc7377f9084a4b8d869e48b4ae7a9891a1b195e66dc56ecc9b9ee/pyobjc_framework_audiovideobridging-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:472917360aee1c74012f2ff682fdfe6fb52c5bcf3214bf46121c13085ee82edd", size = 11047, upload-time = "2025-10-21T07:54:32.648Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f8/c614630fa382720bbd42a0ff567378630c36d10f114476d6c70b73f73b49/pyobjc_framework_audiovideobridging-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6bc24a7063b08c7d9f1749a4641430d363b6dba642c04d09b58abcee7a5260cb", size = 11037, upload-time = "2025-11-14T09:36:32.583Z" }, ] [[package]] name = "pyobjc-framework-authenticationservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/09/2e51e8e72a72536c3721124bdd6ac93f88ec28ad352a35437536ec08c70f/pyobjc_framework_authenticationservices-12.0.tar.gz", hash = "sha256:6dbc94140584d439d5106fd3b64db97c3681ff27c9b3793a6e7885df9974af16", size = 58917, upload-time = "2025-10-21T08:26:46.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/18/86218de3bf67fc1d810065f353d9df70c740de567ebee8550d476cb23862/pyobjc_framework_authenticationservices-12.1.tar.gz", hash = "sha256:cef71faeae2559f5c0ff9a81c9ceea1c81108e2f4ec7de52a98c269feff7a4b6", size = 58683, upload-time = "2025-11-14T10:09:06.003Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/78/87aceec2f0586cfbf6560916cdbe954dc419135f335dda1ec7194d24c3cb/pyobjc_framework_authenticationservices-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:24bc6e5855a2029a9d23cd8b209d574fa55d3cadcab5c91c357c78fea90a31eb", size = 20632, upload-time = "2025-10-21T07:54:47.099Z" }, + { url = "https://files.pythonhosted.org/packages/c2/16/2f19d8a95f0cf8e940f7b7fb506ced805d5522b4118336c8e640c34517ae/pyobjc_framework_authenticationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c15bb81282356f3f062ac79ff4166c93097448edc44b17dcf686e1dac78cc832", size = 20636, upload-time = "2025-11-14T09:36:48.35Z" }, ] [[package]] name = "pyobjc-framework-automaticassessmentconfiguration" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/74/e1bb0cfd93cfbdfec173c141d2bbb619e9b500551209ba9d8da81e896665/pyobjc_framework_automaticassessmentconfiguration-12.0.tar.gz", hash = "sha256:8922e5366d2cd6e09f8366e85afe012f9b7fa81d192f98674daa55f098de3f1e", size = 22045, upload-time = "2025-10-21T08:26:48.589Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/24/080afe8189c47c4bb3daa191ccfd962400ca31a67c14b0f7c2d002c2e249/pyobjc_framework_automaticassessmentconfiguration-12.1.tar.gz", hash = "sha256:2b732c02d9097682ca16e48f5d3b10056b740bc091e217ee4d5715194c8970b1", size = 21895, upload-time = "2025-11-14T10:09:08.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/02/8c5b940ec9b99e6b0063fed93348139c58843fdb94dcdadad4fd48fb5b70/pyobjc_framework_automaticassessmentconfiguration-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:81bcf67f109557600ac461c14c0ee0f0a87d3c3b8bc7f9a7b44eec6540b97164", size = 9278, upload-time = "2025-10-21T07:55:04.609Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c9/4d2785565cc470daa222f93f3d332af97de600aef6bd23507ec07501999d/pyobjc_framework_automaticassessmentconfiguration-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d94a4a3beb77b3b2ab7b610c4b41e28593d15571724a9e6ab196b82acc98dc13", size = 9316, upload-time = "2025-11-14T09:37:05.052Z" }, ] [[package]] name = "pyobjc-framework-automator" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/d3/17178d3c6fde3f95718f9832a799d2328e59ba5158d1434fe2767c957187/pyobjc_framework_automator-12.0.tar.gz", hash = "sha256:7c2f0236b2a474a2d411835419e8f140e0f563be299f770fe8762f96d254443d", size = 186429, upload-time = "2025-10-21T08:27:01.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/08/362bf6ac2bba393c46cf56078d4578b692b56857c385e47690637a72f0dd/pyobjc_framework_automator-12.1.tar.gz", hash = "sha256:7491a99347bb30da3a3f744052a03434ee29bee3e2ae520576f7e796740e4ba7", size = 186068, upload-time = "2025-11-14T10:09:20.82Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/fd/4e8e6ee1917a978394bd8dfa4972ba98a106e426835ab7782667f38b04ea/pyobjc_framework_automator-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3cb965d6b3a6dcb2341fac4e33538b828e84a0e449e377c647f1cf44b7c19203", size = 10016, upload-time = "2025-10-21T07:55:16.911Z" }, + { url = "https://files.pythonhosted.org/packages/e7/99/480e07eef053a2ad2a5cf1e15f71982f21d7f4119daafac338fa0352309c/pyobjc_framework_automator-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f3d96da10d28c5c197193a9d805a13157b1cb694b6c535983f8572f5f8746ea", size = 10016, upload-time = "2025-11-14T09:37:18.621Z" }, ] [[package]] name = "pyobjc-framework-avfoundation" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -1048,54 +1077,54 @@ dependencies = [ { name = "pyobjc-framework-coremedia" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/95/29d3dbf7bfa6f2beb865ab4ce22ee1ccd58c2036a6c4caa6fa6568c7a727/pyobjc_framework_avfoundation-12.0.tar.gz", hash = "sha256:e9e9a15edea43341b39de677a58ac98b2a6bd4d6c55176b4804c5f75b3d20ece", size = 310508, upload-time = "2025-10-21T08:27:21.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/42/c026ab308edc2ed5582d8b4b93da6b15d1b6557c0086914a4aabedd1f032/pyobjc_framework_avfoundation-12.1.tar.gz", hash = "sha256:eda0bb60be380f9ba2344600c4231dd58a3efafa99fdc65d3673ecfbb83f6fcb", size = 310047, upload-time = "2025-11-14T10:09:40.069Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/b6/cd14afee737a14b959ec9f96017134b80bdab55649b82f34f5490c060790/pyobjc_framework_avfoundation-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d47cd250011e6db5e20f1ff6ad72b6d2c40364eb6565009c7d2ff071e0a89647", size = 83319, upload-time = "2025-10-21T07:55:38.449Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5a/4ef36b309138840ff8cd85364f66c29e27023f291004c335a99f6e87e599/pyobjc_framework_avfoundation-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82cc2c2d9ab6cc04feeb4700ff251d00f1fcafff573c63d4e87168ff80adb926", size = 83328, upload-time = "2025-11-14T09:37:40.808Z" }, ] [[package]] name = "pyobjc-framework-avkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/65/2de0788c5ecde6906b9acfe1c37c6be59f9527eeb44b6fc494c63584edb9/pyobjc_framework_avkit-12.0.tar.gz", hash = "sha256:0f1ea37cd19483c62ba7a42e73dc07a03a0656ce916e772d13b017c625757930", size = 28881, upload-time = "2025-10-21T08:27:24.941Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/a9/e44db1a1f26e2882c140f1d502d508b1f240af9048909dcf1e1a687375b4/pyobjc_framework_avkit-12.1.tar.gz", hash = "sha256:a5c0ddb0cb700f9b09c8afeca2c58952d554139e9bb078236d2355b1fddfb588", size = 28473, upload-time = "2025-11-14T10:09:43.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/4d/087d8d19adda2478e314bbf27ae6f7de734fc4f8bca2c731c024bca167e7/pyobjc_framework_avkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dedab05ba28e6b2f09c72b8a232522e24980f250d7950f72a986edafd282c979", size = 11590, upload-time = "2025-10-21T07:56:14.304Z" }, + { url = "https://files.pythonhosted.org/packages/8c/68/409ee30f3418b76573c70aa05fa4c38e9b8b1d4864093edcc781d66019c2/pyobjc_framework_avkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:78bd31a8aed48644e5407b444dec8b1e15ff77af765607b52edf88b8f1213ac7", size = 11583, upload-time = "2025-11-14T09:38:17.569Z" }, ] [[package]] name = "pyobjc-framework-avrouting" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4e/98/cc2316849224736b9386189a52c80a73a154979a24c8877faa1be258a3b0/pyobjc_framework_avrouting-12.0.tar.gz", hash = "sha256:01edbba4257450bb42b87deb8c2498fc30e6d7a2adc9b25c81e118af5bdf7dac", size = 20432, upload-time = "2025-10-21T08:27:27.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/83/15bf6c28ec100dae7f92d37c9e117b3b4ee6b4873db062833e16f1cfd6c4/pyobjc_framework_avrouting-12.1.tar.gz", hash = "sha256:6a6c5e583d14f6501df530a9d0559a32269a821fc8140e3646015f097155cd1c", size = 20031, upload-time = "2025-11-14T10:09:45.701Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/99/02cae8b7c7174a962677d817d5cee71319b4f30614ab988f571cb050b13b/pyobjc_framework_avrouting-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ee895f51745235db6ee32c9d1f807a9d0ca10f32c1827428b81a308670ff700b", size = 8446, upload-time = "2025-10-21T07:56:26.771Z" }, + { url = "https://files.pythonhosted.org/packages/69/a7/5c5725db9c91b492ffbd4ae3e40025deeb9e60fcc7c8fbd5279b52280b95/pyobjc_framework_avrouting-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9a79f05fb66e337cabc19a9d949c8b29a5145c879f42e29ba02b601b7700d1bb", size = 8431, upload-time = "2025-11-14T09:38:33.018Z" }, ] [[package]] name = "pyobjc-framework-backgroundassets" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/d6/143de9d93121fae5201c18ca3b5dcf155f3abc6cabed946ab20f52b99572/pyobjc_framework_backgroundassets-12.0.tar.gz", hash = "sha256:f9bcfba27ffec725620e87778a26b783e3955343adcc96e3d5635edcc4cb1207", size = 26625, upload-time = "2025-10-21T08:27:29.629Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/d1/e917fba82790495152fd3508c5053827658881cf7e9887ba60def5e3f221/pyobjc_framework_backgroundassets-12.1.tar.gz", hash = "sha256:8da34df9ae4519c360c429415477fdaf3fbba5addbc647b3340b8783454eb419", size = 26210, upload-time = "2025-11-14T10:09:48.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/87/3972cda9f3462066fa95d8b620f786abf4aea056cc5a955d4c2d52e21966/pyobjc_framework_backgroundassets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cc0a7b24f58146d2e03b5d8de1f8ea26d313f791328f2f6067f720e15e84f64f", size = 10771, upload-time = "2025-10-21T07:56:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/c1/49/33c1c3eaf26a7d89dd414e14939d4f02063d66252d0f51c02082350223e0/pyobjc_framework_backgroundassets-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:17de7990b5ea8047d447339f9e9e6f54b954ffc06647c830932a1688c4743fea", size = 10763, upload-time = "2025-11-14T09:38:46.671Z" }, ] [[package]] name = "pyobjc-framework-browserenginekit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -1104,79 +1133,79 @@ dependencies = [ { name = "pyobjc-framework-coremedia" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/a3/fe0015c88f576e42702a96c33d9d8c4f0195f32017f81d224e3f2238905b/pyobjc_framework_browserenginekit-12.0.tar.gz", hash = "sha256:8409031977ee725b258e96096a2ad2910c11753865d8e79aa6c8c154a98a55a6", size = 29480, upload-time = "2025-10-21T08:27:32.699Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/b9/39f9de1730e6f8e73be0e4f0c6087cd9439cbe11645b8052d22e1fb8e69b/pyobjc_framework_browserenginekit-12.1.tar.gz", hash = "sha256:6a1a34a155778ab55ab5f463e885f2a3b4680231264e1fe078e62ddeccce49ed", size = 29120, upload-time = "2025-11-14T10:09:51.582Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/e9/dd169256d5693f9f35ed3169009ba70544c305f90a34ccbc79b0f036601b/pyobjc_framework_browserenginekit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ce95e87b533c12fc70dcf10c7ca4ec6862ea00dd3ee076b8b0f6f66110771771", size = 11531, upload-time = "2025-10-21T07:56:52.905Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a4/2d576d71b2e4b3e1a9aa9fd62eb73167d90cdc2e07b425bbaba8edd32ff5/pyobjc_framework_browserenginekit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41229c766fb3e5bba2de5e580776388297303b4d63d3065fef3f67b77ec46c3f", size = 11526, upload-time = "2025-11-14T09:38:58.861Z" }, ] [[package]] name = "pyobjc-framework-businesschat" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/74/a34367bab4b74126897e37b5838e47c135407950bd843fddd115ffb75428/pyobjc_framework_businesschat-12.0.tar.gz", hash = "sha256:2f598056f1a90a5a85ef3c75c8457f8cd80511017982a17ddb28695a6bf205f6", size = 12127, upload-time = "2025-10-21T08:27:34.516Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/da/bc09b6ed19e9ea38ecca9387c291ca11fa680a8132d82b27030f82551c23/pyobjc_framework_businesschat-12.1.tar.gz", hash = "sha256:f6fa3a8369a1a51363e1757530128741d9d09ed90692a1d6777a4c0fbad25868", size = 12055, upload-time = "2025-11-14T10:09:53.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/41/3f41a8a7c2443cc8e2d6a6cbc19444d9a56ebd000b16246573fc5bb6d2f1/pyobjc_framework_businesschat-12.0-py2.py3-none-any.whl", hash = "sha256:a3faa5a6be27fd18f2b0d34306d8cb8e81c1f2c1f637239b4c9b9f5d90e322ee", size = 3482, upload-time = "2025-10-21T07:57:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/53/88/4c727424b05efa33ed7f6c45e40333e5a8a8dc5bb238e34695addd68463b/pyobjc_framework_businesschat-12.1-py2.py3-none-any.whl", hash = "sha256:f66ce741507b324de3c301d72ba0cfa6aaf7093d7235972332807645c118cc29", size = 3474, upload-time = "2025-11-14T09:39:10.771Z" }, ] [[package]] name = "pyobjc-framework-calendarstore" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/6d/62bf488ca94108fa8820a691b41da62aa69daeef3bca86f14af1f576a5a3/pyobjc_framework_calendarstore-12.0.tar.gz", hash = "sha256:cfdac6543090d7790c576e24ff87440d3b57e234a51e9468bdbb5451b4d94c9b", size = 52284, upload-time = "2025-10-21T08:27:39.643Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/41/ae955d1c44dcc18b5b9df45c679e9a08311a0f853b9d981bca760cf1eef2/pyobjc_framework_calendarstore-12.1.tar.gz", hash = "sha256:f9a798d560a3c99ad4c0d2af68767bc5695d8b1aabef04d8377861cd1d6d1670", size = 52272, upload-time = "2025-11-14T10:09:58.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/f8/678b8725046e320a3183c232349af205567b0489dda818eb7572a1a7b8e0/pyobjc_framework_calendarstore-12.0-py2.py3-none-any.whl", hash = "sha256:32432f4fddf080f8a5d592a2dc659f30bde9486c89dc0978fee5faec7847a076", size = 5295, upload-time = "2025-10-21T07:57:05.732Z" }, + { url = "https://files.pythonhosted.org/packages/fa/70/f68aebdb7d3fa2dec2e9da9e9cdaa76d370de326a495917dbcde7bb7711e/pyobjc_framework_calendarstore-12.1-py2.py3-none-any.whl", hash = "sha256:18533e0fcbcdd29ee5884dfbd30606710f65df9b688bf47daee1438ee22e50cc", size = 5285, upload-time = "2025-11-14T09:39:12.473Z" }, ] [[package]] name = "pyobjc-framework-callkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/2a/b0ed29456b1d55bb2764768bcd2668cbf2f746a27a67854da71d89e4609b/pyobjc_framework_callkit-12.0.tar.gz", hash = "sha256:fab030e3e5c33d245f3b00165b5cf366ae43846ce237e3d4a0874198c17d8d60", size = 29544, upload-time = "2025-10-21T08:27:42.462Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c0/1859d4532d39254df085309aff55b85323576f00a883626325af40da4653/pyobjc_framework_callkit-12.1.tar.gz", hash = "sha256:fd6dc9688b785aab360139d683be56f0844bf68bf5e45d0eb770cb68221083cc", size = 29171, upload-time = "2025-11-14T10:10:01.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/be/0d3e91da5b873759373590e5fa7b0de5f3d3ecc57fbda8a659240906183f/pyobjc_framework_callkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:baff4db6c268f18e4035d136d10e9fa4a58504ff41e201a7a2148aa91b4e0797", size = 11282, upload-time = "2025-10-21T07:57:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f6/aafd14b31e00d59d830f9a8e8e46c4f41a249f0370499d5b017599362cf1/pyobjc_framework_callkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e73beae08e6a32bcced8d5bdb45b52d6a0866dd1485eaaddba6063f17d41fcb0", size = 11273, upload-time = "2025-11-14T09:39:16.837Z" }, ] [[package]] name = "pyobjc-framework-carbon" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0a/86/e5212c091d614f5097fb34d06820fda00d4dc2dcc0ac68d102b8cb0a79ac/pyobjc_framework_carbon-12.0.tar.gz", hash = "sha256:ad24c6c9def13669f9b6dc2350b39ac96270f4918223d1abf4d8a70990eed84c", size = 37320, upload-time = "2025-10-21T08:27:45.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/0f/9ab8e518a4e5ac4a1e2fdde38a054c32aef82787ff7f30927345c18b7765/pyobjc_framework_carbon-12.1.tar.gz", hash = "sha256:57a72807db252d5746caccc46da4bd20ff8ea9e82109af9f72735579645ff4f0", size = 37293, upload-time = "2025-11-14T10:10:04.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/aa/56b0bc78523ca3ecdf6e72a8b786b7204364c57d1b2db17bb50cfed1091d/pyobjc_framework_carbon-12.0-py2.py3-none-any.whl", hash = "sha256:b58d0f558f3f31e981c26a1074fce8a32bf0aa6f9c6bccefdb2828a4f9c46eac", size = 4635, upload-time = "2025-10-21T07:57:21.073Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/91853c8f98b9d5bccf464113908620c94cc12c2a3e4625f3ce172e3ea4bc/pyobjc_framework_carbon-12.1-py2.py3-none-any.whl", hash = "sha256:f8b719b3c7c5cf1d61ac7c45a8a70b5e5e5a83fa02f5194c2a48a7e81a3d1b7f", size = 4625, upload-time = "2025-11-14T09:39:27.937Z" }, ] [[package]] name = "pyobjc-framework-cfnetwork" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/92/910990becf6e6205787a9e1a1ce6847358fab73b76949283a053c7cd8d54/pyobjc_framework_cfnetwork-12.0.tar.gz", hash = "sha256:b6c3d156c774f8c5fc2bfb3efc311c62cfd317ddaffb4d6637821039e852e3f1", size = 44831, upload-time = "2025-10-21T08:27:49.303Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/6a/f5f0f191956e187db85312cbffcc41bf863670d121b9190b4a35f0d36403/pyobjc_framework_cfnetwork-12.1.tar.gz", hash = "sha256:2d16e820f2d43522c793f55833fda89888139d7a84ca5758548ba1f3a325a88d", size = 44383, upload-time = "2025-11-14T10:10:08.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/34/8905bb4c86d89c6e502f3ba2dddaa436db18d532b0b535b101b8883759f9/pyobjc_framework_cfnetwork-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fa4217f7d855d988e7f6799ed3941e312990d4e1d2ce43820e581c87c5383fe2", size = 18957, upload-time = "2025-10-21T07:57:25.671Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7e/82aca783499b690163dd19d5ccbba580398970874a3431bfd7c14ceddbb3/pyobjc_framework_cfnetwork-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3bf93c0f3d262f629e72f8dd43384d0930ed8e610b3fc5ff555c0c1a1e05334a", size = 18949, upload-time = "2025-11-14T09:39:32.924Z" }, ] [[package]] name = "pyobjc-framework-cinematic" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -1185,27 +1214,27 @@ dependencies = [ { name = "pyobjc-framework-coremedia" }, { name = "pyobjc-framework-metal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/73/803108294b8345056fcfdd592e4652155080b47fc1f977bcbac6d360adab/pyobjc_framework_cinematic-12.0.tar.gz", hash = "sha256:4b0592f975a24192ef46f28b5ea811c2a7ed15d145974da173c93f39819b911f", size = 21218, upload-time = "2025-10-21T08:27:51.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/4e/f4cc7f9f7f66df0290c90fe445f1ff5aa514c6634f5203fe049161053716/pyobjc_framework_cinematic-12.1.tar.gz", hash = "sha256:795068c30447548c0e8614e9c432d4b288b13d5614622ef2f9e3246132329b06", size = 21215, upload-time = "2025-11-14T10:10:10.795Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/38/9779f870b59383d063030d095d50e7a37e3f1f11e5ba782a6fdbaab5cbe6/pyobjc_framework_cinematic-12.0-py2.py3-none-any.whl", hash = "sha256:2c8a4e862731a623e7a4c29e466a4ad9ee7630653567aa32c586914e16f91ae7", size = 5042, upload-time = "2025-10-21T07:57:39.419Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a0/cd85c827ce5535c08d936e5723c16ee49f7ff633f2e9881f4f58bf83e4ce/pyobjc_framework_cinematic-12.1-py2.py3-none-any.whl", hash = "sha256:c003543bb6908379680a93dfd77a44228686b86c118cf3bc930f60241d0cd141", size = 5031, upload-time = "2025-11-14T09:39:49.003Z" }, ] [[package]] name = "pyobjc-framework-classkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/a5/e6a3cb61d2e7579376c11282c504445e5ad38c9cd6220f62949b863ef5df/pyobjc_framework_classkit-12.0.tar.gz", hash = "sha256:a8511b242a7092e79e0f97cc50f0f2fe4b28f92710f3c3242247334227818820", size = 26664, upload-time = "2025-10-21T08:27:54.802Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/ef/67815278023b344a79c7e95f748f647245d6f5305136fc80615254ad447c/pyobjc_framework_classkit-12.1.tar.gz", hash = "sha256:8d1e9dd75c3d14938ff533d88b72bca2d34918e4461f418ea323bfb2498473b4", size = 26298, upload-time = "2025-11-14T10:10:13.406Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/91/963ffc9575e5b0757911fef921ed668ec642ba3916faec58717a4f5f82dd/pyobjc_framework_classkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:86a8d5c8c56ec8c9592020ac6c50bab82f81e48e382a95f0f5ef7b2509117315", size = 8867, upload-time = "2025-10-21T07:57:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/67bd062fbc9761c34b9911ed099ee50ccddc3032779ce420ca40083ee15c/pyobjc_framework_classkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bd90aacc68eff3412204a9040fa81eb18348cbd88ed56d33558349f3e51bff52", size = 8857, upload-time = "2025-11-14T09:39:53.283Z" }, ] [[package]] name = "pyobjc-framework-cloudkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -1214,870 +1243,870 @@ dependencies = [ { name = "pyobjc-framework-coredata" }, { name = "pyobjc-framework-corelocation" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/dc/539f3a4c2b490adc2079f111b6594e847cd9fdb10d44b65b629977673c44/pyobjc_framework_cloudkit-12.0.tar.gz", hash = "sha256:1ac29d81005b92575ce6a5c9bdbb8fec50cd9fadaaab66db972934e5e542cf1c", size = 53756, upload-time = "2025-10-21T08:27:59.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/09/762ee4f3ae8568b8e0e5392c705bc4aa1929aa454646c124ca470f1bf9fc/pyobjc_framework_cloudkit-12.1.tar.gz", hash = "sha256:1dddd38e60863f88adb3d1d37d3b4ccb9cbff48c4ef02ab50e36fa40c2379d2f", size = 53730, upload-time = "2025-11-14T10:10:17.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/67/5bbc583777376642c103a327930c11bca0c3eb3a1ceaad20dfaf55be96eb/pyobjc_framework_cloudkit-12.0-py2.py3-none-any.whl", hash = "sha256:1ad9af5c0ef94e147cd8c5676aab7925ead9da8398bd01898597c4da7cb3231b", size = 11102, upload-time = "2025-10-21T07:57:53.771Z" }, + { url = "https://files.pythonhosted.org/packages/35/71/cbef7179bf1a594558ea27f1e5ad18f5c17ef71a8a24192aae16127bc849/pyobjc_framework_cloudkit-12.1-py2.py3-none-any.whl", hash = "sha256:875e37bf1a2ce3d05c2492692650104f2d908b56b71a0aedf6620bc517c6c9ca", size = 11090, upload-time = "2025-11-14T09:40:04.207Z" }, ] [[package]] name = "pyobjc-framework-cocoa" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/6f/89837da349fe7de6476c426f118096b147de923139556d98af1832c64b97/pyobjc_framework_cocoa-12.0.tar.gz", hash = "sha256:02d69305b698015a20fcc8e1296e1528e413d8cf9fdcd590478d359386d76e8a", size = 2771906, upload-time = "2025-10-21T08:30:51.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/7d/1758df5c2cbf9a0a447cab7e9e5690f166c8b2117dc15d8f38a9526af9db/pyobjc_framework_cocoa-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae041b7c64a8fa93f0e06728681f7ad657ef2c92dcfdf8abc073d89fb6e3910b", size = 383765, upload-time = "2025-10-21T07:58:44.189Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, ] [[package]] name = "pyobjc-framework-collaboration" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/df/611e4f31a4ad32bc85d39f049006d7013fde6eec57f798714d13c3e02c70/pyobjc_framework_collaboration-12.0.tar.gz", hash = "sha256:7090d493adeffee2d6abcf2ce85d79cb273448b7624284ea7ede166e1a9daf7f", size = 14322, upload-time = "2025-10-21T08:30:54.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/21/77fe64b39eae98412de1a0d33e9c735aa9949d53fff6b2d81403572b410b/pyobjc_framework_collaboration-12.1.tar.gz", hash = "sha256:2afa264d3233fc0a03a56789c6fefe655ffd81a2da4ba1dc79ea0c45931ad47b", size = 14299, upload-time = "2025-11-14T10:13:04.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/a7/02070855162d0b997884fffcc42976cead4de3e764f7b3b234fd9c23f2b2/pyobjc_framework_collaboration-12.0-py2.py3-none-any.whl", hash = "sha256:f3d5bf79ed1012068c279b46225b23236e4c099d549421192c89468d591c40cc", size = 4915, upload-time = "2025-10-21T08:00:49.897Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/1507de01f1e2b309f8e11553a52769e4e2e9939ed770b5b560ef5bc27bc1/pyobjc_framework_collaboration-12.1-py2.py3-none-any.whl", hash = "sha256:182d6e6080833b97f9bef61738ae7bacb509714538f0d7281e5f0814c804b315", size = 4907, upload-time = "2025-11-14T09:42:55.781Z" }, ] [[package]] name = "pyobjc-framework-colorsync" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/81/efc29f6af5fb9c1c483c3035c3020e0e6932f8d975972e0f9c71a31615f6/pyobjc_framework_colorsync-12.0.tar.gz", hash = "sha256:9733cef2d4641cbd308fc3f33b8fba07f34ed1e58bf45a4d982289c9c6706156", size = 25015, upload-time = "2025-10-21T08:30:57.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/b4/706e4cc9db25b400201fc90f3edfaa1ab2d51b400b19437b043a68532078/pyobjc_framework_colorsync-12.1.tar.gz", hash = "sha256:d69dab7df01245a8c1bd536b9231c97993a5d1a2765d77692ce40ebbe6c1b8e9", size = 25269, upload-time = "2025-11-14T10:13:07.522Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/10/6e1025a7aaa9b7d5bbd97b0ff462a40880b0ded608e7ec5c87c5f50100ae/pyobjc_framework_colorsync-12.0-py2.py3-none-any.whl", hash = "sha256:68c24293b0613796521172964c2b579b76794bcbb62f1d045ef5539e60b91626", size = 5963, upload-time = "2025-10-21T08:00:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e1/82e45c712f43905ee1e6d585180764e8fa6b6f1377feb872f9f03c8c1fb8/pyobjc_framework_colorsync-12.1-py2.py3-none-any.whl", hash = "sha256:41e08d5b9a7af4b380c9adab24c7ff59dfd607b3073ae466693a3e791d8ffdc9", size = 6020, upload-time = "2025-11-14T09:42:57.504Z" }, ] [[package]] name = "pyobjc-framework-compositorservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-metal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/0c/e7e6b4b329691804bf4dd5a4c05e7e3432b929265c914e38d09de80b629b/pyobjc_framework_compositorservices-12.0.tar.gz", hash = "sha256:c2d47153e6d180d0040235b8a61d58d1c9659f55df933fd4f16a55f281fcf9c9", size = 23309, upload-time = "2025-10-21T08:30:59.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/c5/0ba31d7af7e464b7f7ece8c2bd09112bdb0b7260848402e79ba6aacc622c/pyobjc_framework_compositorservices-12.1.tar.gz", hash = "sha256:028e357bbee7fbd3723339a321bbe14e6da5a772708a661a13eea5f17c89e4ab", size = 23292, upload-time = "2025-11-14T10:13:10.392Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/26/83bf8f230ae22ab531c2870ef33a85c3d36aef05d3efd0a5899a68531b96/pyobjc_framework_compositorservices-12.0-py2.py3-none-any.whl", hash = "sha256:71f98346eb05c240a3b4c3f0d5399dbadd4dbb73b74bea24600065c9ef9d453f", size = 5918, upload-time = "2025-10-21T08:00:53.527Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/5a2de8d531dbb88023898e0b5d2ce8edee14751af6c70e6103f6aa31a669/pyobjc_framework_compositorservices-12.1-py2.py3-none-any.whl", hash = "sha256:9ef22d4eacd492e13099b9b8936db892cdbbef1e3d23c3484e0ed749f83c4984", size = 5910, upload-time = "2025-11-14T09:42:59.154Z" }, ] [[package]] name = "pyobjc-framework-contacts" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/fb/9e60e4db4a4f4c02be4b0ba2d59ea116db230e1f4de134247d3390168dcb/pyobjc_framework_contacts-12.0.tar.gz", hash = "sha256:ac921f8ef7bf3767b335d8055f597b03ad6845dfd93c05647cf41550af6dcda3", size = 42727, upload-time = "2025-10-21T08:31:03.189Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/a0/ce0542d211d4ea02f5cbcf72ee0a16b66b0d477a4ba5c32e00117703f2f0/pyobjc_framework_contacts-12.1.tar.gz", hash = "sha256:89bca3c5cf31404b714abaa1673577e1aaad6f2ef49d4141c6dbcc0643a789ad", size = 42378, upload-time = "2025-11-14T10:13:14.203Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/94/55c18e908a9e25e47b2649e1c9ac4a5eb79d4d8595cf2585324d00ce32c5/pyobjc_framework_contacts-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1929f3c9de057542da9d292d8ab0d40dfc086b24acf50739f7d590ac7486d13d", size = 12093, upload-time = "2025-10-21T08:00:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/94/f5/5d2c03cf5219f2e35f3f908afa11868e9096aff33b29b41d63f2de3595f2/pyobjc_framework_contacts-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ab86070895a005239256d207e18209b1a79d35335b6604db160e8375a7165e6", size = 12086, upload-time = "2025-11-14T09:43:03.225Z" }, ] [[package]] name = "pyobjc-framework-contactsui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-contacts" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/9b/eb41bfdad0a2049f27559e0d152b1bb6cc1d001cc9ebf97fb94f548bc3ea/pyobjc_framework_contactsui-12.0.tar.gz", hash = "sha256:98bed7b93b0934786f6ddd9644c80175a40a593a0a4ffd8128ef7885bc377f5a", size = 19163, upload-time = "2025-10-21T08:31:05.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/0c/7bb7f898456a81d88d06a1084a42e374519d2e40a668a872b69b11f8c1f9/pyobjc_framework_contactsui-12.1.tar.gz", hash = "sha256:aaeca7c9e0c9c4e224d73636f9a558f9368c2c7422155a41fd4d7a13613a77c1", size = 18769, upload-time = "2025-11-14T10:13:16.301Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/bb/0aaf1fc166646156a746fad066a50d2191aa06e975bb9f55d880633e0ead/pyobjc_framework_contactsui-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ffc7837b2bbddc1c4e830bcee07d976f87a2827422f16fd7612fe8b1fd4332a1", size = 7880, upload-time = "2025-10-21T08:01:12.55Z" }, + { url = "https://files.pythonhosted.org/packages/04/e3/8d330640bf0337289834334c54c599fec2dad38a8a3b736d40bcb5d8db6e/pyobjc_framework_contactsui-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:10e7ce3b105795919605be89ebeecffd656e82dbf1bafa5db6d51d6def2265ee", size = 7871, upload-time = "2025-11-14T09:43:16.973Z" }, ] [[package]] name = "pyobjc-framework-coreaudio" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/a0/604b8e2e53f46536b9045fc0fbfa9468a606910c9c0a238d0f3d31071d87/pyobjc_framework_coreaudio-12.0.tar.gz", hash = "sha256:19741907d2d80a658d3721140eb998061007955323b427afca67eda0e2ad3215", size = 75415, upload-time = "2025-10-21T08:31:12.282Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/d1/0b884c5564ab952ff5daa949128c64815300556019c1bba0cf2ca752a1a0/pyobjc_framework_coreaudio-12.1.tar.gz", hash = "sha256:a9e72925fcc1795430496ce0bffd4ddaa92c22460a10308a7283ade830089fe1", size = 75077, upload-time = "2025-11-14T10:13:22.345Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/42/284cc68a2bd310f4399eb92e5259319a3131b1fba5f1496dfaa477eaaed0/pyobjc_framework_coreaudio-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6287d67c7b3ca9abf4b7e8a64e1a05e97ebcb52b32e92a78e1e825d1334ec56", size = 35337, upload-time = "2025-10-21T08:01:29.747Z" }, + { url = "https://files.pythonhosted.org/packages/9e/25/491ff549fd9a40be4416793d335bff1911d3d1d1e1635e3b0defbd2cf585/pyobjc_framework_coreaudio-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a452de6b509fa4a20160c0410b72330ac871696cd80237883955a5b3a4de8f2a", size = 35327, upload-time = "2025-11-14T09:43:32.523Z" }, ] [[package]] name = "pyobjc-framework-coreaudiokit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-coreaudio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/4e/9c55aa44e330cbbecf47c41fd1804128057422ae9ef2349db8c122c9ffb2/pyobjc_framework_coreaudiokit-12.0.tar.gz", hash = "sha256:2f02896167adf3f420ab8dd55a41c905e42ed59edf21a6f5f6d4d2f16b8b67a8", size = 20519, upload-time = "2025-10-21T08:31:14.66Z" } +sdist = { url = "https://files.pythonhosted.org/packages/41/1c/5c7e39b9361d4eec99b9115b593edd9825388acd594cb3b4519f8f1ac12c/pyobjc_framework_coreaudiokit-12.1.tar.gz", hash = "sha256:b83624f8de3068ab2ca279f786be0804da5cf904ff9979d96007b69ef4869e1e", size = 20137, upload-time = "2025-11-14T10:13:24.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/b3/c5723b94ba5d054971b8e6e5d4cefbd7664892556259e41fd911202227f9/pyobjc_framework_coreaudiokit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0ddca463bd0adc3cd67ef2ae345c066f792ebddd8113903e06e2b6bab23750e3", size = 7256, upload-time = "2025-10-21T08:01:51.444Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/e4233fbe5b94b124f5612e1edc130a9280c4674a1d1bf42079ea14b816e1/pyobjc_framework_coreaudiokit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e1144c272f8d6429a34a6757700048f4631eb067c4b08d4768ddc28c371a7014", size = 7250, upload-time = "2025-11-14T09:43:53.208Z" }, ] [[package]] name = "pyobjc-framework-corebluetooth" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/b2/ad9e8516cd73611a3a8f8ff2d7d51b917115f3f7f9e7a9760d5fc4e9dd6b/pyobjc_framework_corebluetooth-12.0.tar.gz", hash = "sha256:61ae2a56c3dcb8b7307d833e7d913bd7c063d11a1ea931158facceb38aae21d3", size = 33587, upload-time = "2025-10-21T08:31:18.036Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157, upload-time = "2025-11-14T10:13:28.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/ef/4190181375f38d1223cd022fb526cc1ec1c1708937482203141ab1238fbb/pyobjc_framework_corebluetooth-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ab59e55ab6c71fcbe747359eb1119771021231fade3c5ceae6e8a5d542e32450", size = 13200, upload-time = "2025-10-21T08:02:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/26ae106beb97e9c4745065edb3ce3c2bdd91d81f5b52b8224f82ce9d5fb9/pyobjc_framework_corebluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:37e6456c8a076bd5a2bdd781d0324edd5e7397ef9ac9234a97433b522efb13cf", size = 13189, upload-time = "2025-11-14T09:44:06.229Z" }, ] [[package]] name = "pyobjc-framework-coredata" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/ad/391d4c821c37ccf1a15ac13579c8f1eac8114a95b97d5904c9566ad4d593/pyobjc_framework_coredata-12.0.tar.gz", hash = "sha256:b9955d3b5951de8025cb24646281e42e85f37233150e4c7c62f1e2961088488b", size = 124704, upload-time = "2025-10-21T08:31:26.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/c5/8cd46cd4f1b7cf88bdeed3848f830ea9cdcc4e55cd0287a968a2838033fb/pyobjc_framework_coredata-12.1.tar.gz", hash = "sha256:1e47d3c5e51fdc87a90da62b97cae1bc49931a2bb064db1305827028e1fc0ffa", size = 124348, upload-time = "2025-11-14T10:13:36.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/50/11f57e33b290bc3d34a7901584761965bf273248ddc0ef9eab276e2fa709/pyobjc_framework_coredata-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5e51e6b80bd9151fe09be4084954c26f8c4332367bf2ea60347617491b477152", size = 16401, upload-time = "2025-10-21T08:02:20.787Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a8/4c694c85365071baef36013a7460850dcf6ebfea0ba239e52d7293cdcb93/pyobjc_framework_coredata-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c861dc42b786243cbd96d9ea07d74023787d03637ef69a2f75a1191a2f16d9d6", size = 16395, upload-time = "2025-11-14T09:44:21.105Z" }, ] [[package]] name = "pyobjc-framework-corehaptics" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/3a/040fc7a9dfebe59825cf71749d1085cdbd21a2b9192efbe0333407d7c2e4/pyobjc_framework_corehaptics-12.0.tar.gz", hash = "sha256:f2de5699473162421522347a090285f5394da7fd23da5008c1f18229678d84bf", size = 22150, upload-time = "2025-10-21T08:31:29.333Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/2f/74a3da79d9188b05dd4be4428a819ea6992d4dfaedf7d629027cf1f57bfc/pyobjc_framework_corehaptics-12.1.tar.gz", hash = "sha256:521dd2986c8a4266d583dd9ed9ae42053b11ae7d3aa89bf53fbee88307d8db10", size = 22164, upload-time = "2025-11-14T10:13:38.941Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f0/928ebf2bae947ead0cf9aba49ad6f1085c4fa6c183e75d6719539348d2fe/pyobjc_framework_corehaptics-12.0-py2.py3-none-any.whl", hash = "sha256:b04d1a7895b7c56371971bc87aacbb604bb3778896cab3d81d97caef4e89240a", size = 5390, upload-time = "2025-10-21T08:02:33.396Z" }, + { url = "https://files.pythonhosted.org/packages/25/f4/f469d6a9cac7c195f3d08fa65f94c32dd1dcf97a54b481be648fb3a7a5f3/pyobjc_framework_corehaptics-12.1-py2.py3-none-any.whl", hash = "sha256:a3b07d36ddf5c86a9cdaa411ab53d09553d26ea04fc7d4f82d21a84f0fc05fc0", size = 5382, upload-time = "2025-11-14T09:44:34.725Z" }, ] [[package]] name = "pyobjc-framework-corelocation" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/3a/a196c403b4f911905a5886374054019f3842873cf517f38c728905e0fe55/pyobjc_framework_corelocation-12.0.tar.gz", hash = "sha256:20a6fe17709f17ddbf9dd833a1a0ef045ad2e5838ba777f20eb329ed71c597c6", size = 53900, upload-time = "2025-10-21T08:31:33.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/79/b75885e0d75397dc2fe1ed9ca80be2b64c18b817f5fb924277cb1bf7b163/pyobjc_framework_corelocation-12.1.tar.gz", hash = "sha256:3674e9353f949d91dde6230ad68f6d5748a7f0424751e08a2c09d06050d66231", size = 53511, upload-time = "2025-11-14T10:13:43.384Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/8b/7b08d006d1eb8e44605657434a2f17e7fd16c87eef834081bb323ffca90f/pyobjc_framework_corelocation-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d7417d38bf3ec97c14e87f7fedd8c4a978c27789fe738f15b774eb959dbbbe60", size = 12711, upload-time = "2025-10-21T08:02:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/34/ac/44b6cb414ce647da8328d0ed39f0a8b6eb54e72189ce9049678ce2cb04c3/pyobjc_framework_corelocation-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ffc96b9ba504b35fe3e0fcfb0153e68fdfca6fe71663d240829ceab2d7122588", size = 12700, upload-time = "2025-11-14T09:44:38.717Z" }, ] [[package]] name = "pyobjc-framework-coremedia" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/6d/ed4f8b525a0520e609cea57fd0677bf7792e168297ad5577df1088eb7cd6/pyobjc_framework_coremedia-12.0.tar.gz", hash = "sha256:d7f76d2eb2890be9f8836b95682e83fa7f158c92043958daa71845fbc4a01ba9", size = 89928, upload-time = "2025-10-21T08:31:40.487Z" } +sdist = { url = "https://files.pythonhosted.org/packages/da/7d/5ad600ff7aedfef8ba8f51b11d9aaacdf247b870bd14045d6e6f232e3df9/pyobjc_framework_coremedia-12.1.tar.gz", hash = "sha256:166c66a9c01e7a70103f3ca44c571431d124b9070612ef63a1511a4e6d9d84a7", size = 89566, upload-time = "2025-11-14T10:13:49.788Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/1c/5e5fe69b142c98b844803a0579cbd8ea555d1bfeecede95a918e58bdfb67/pyobjc_framework_coremedia-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed5684c764e1d4eab10cfd8dcaea82b598a85d7757cef35d36e6c78a4bd4b1e5", size = 29508, upload-time = "2025-10-21T08:02:53.135Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bc/e66de468b3777d8fece69279cf6d2af51d2263e9a1ccad21b90c35c74b1b/pyobjc_framework_coremedia-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ee7b822c9bb674b5b0a70bfb133410acae354e9241b6983f075395f3562f3c46", size = 29503, upload-time = "2025-11-14T09:44:54.716Z" }, ] [[package]] name = "pyobjc-framework-coremediaio" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/4f/903bcf45358beda6efa5c926f66cb8ebe2b4345ea29e17b63c57bb828a28/pyobjc_framework_coremediaio-12.0.tar.gz", hash = "sha256:4067639c463df36831f12a5a87366700e68de054ea2624ee5695c660fe667551", size = 51467, upload-time = "2025-10-21T08:31:44.716Z" } +sdist = { url = "https://files.pythonhosted.org/packages/08/8e/23baee53ccd6c011c965cff62eb55638b4088c3df27d2bf05004105d6190/pyobjc_framework_coremediaio-12.1.tar.gz", hash = "sha256:880b313b28f00b27775d630174d09e0b53d1cdbadb74216618c9dd5b3eb6806a", size = 51100, upload-time = "2025-11-14T10:13:54.277Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/da/34a72c9dddb2651d3e2cf1c0c1d3c9981f721995d9ef6f8338a824c30a08/pyobjc_framework_coremediaio-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4c2dc9cc924927623c5688481106ad75a75c857f4444e37aaced614a69c2d52a", size = 17229, upload-time = "2025-10-21T08:03:12.881Z" }, + { url = "https://files.pythonhosted.org/packages/46/6c/88514f8938719f74aa13abb9fd5492499f1834391133809b4e125c3e7150/pyobjc_framework_coremediaio-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3da79c5b9785c5ccc1f5982de61d4d0f1ba29717909eb6720734076ccdc0633c", size = 17218, upload-time = "2025-11-14T09:45:15.294Z" }, ] [[package]] name = "pyobjc-framework-coremidi" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/e5/705bc151fd4ee430288aaffcbaa965747b4c49564c2e2dcfa44e1208a783/pyobjc_framework_coremidi-12.0.tar.gz", hash = "sha256:0021e76c795e98fe17cefb6eb5b9a312c573ac65e7e732569af0932e9bc4a8c9", size = 55918, upload-time = "2025-10-21T08:31:49.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/96/2d583060a71a73c8a7e6d92f2a02675621b63c1f489f2639e020fae34792/pyobjc_framework_coremidi-12.1.tar.gz", hash = "sha256:3c6f1fd03997c3b0f20ab8545126b1ce5f0cddcc1587dffacad876c161da8c54", size = 55587, upload-time = "2025-11-14T10:13:58.903Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/63/33a66b10725bf5599a5c656fc5295e9e03ced21474b5fe06854df6af4ce1/pyobjc_framework_coremidi-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a67befca6b6b90afb3b4517c647baa7ef0e091d0856bae7fea2594e90fcaf12a", size = 24296, upload-time = "2025-10-21T08:03:30.107Z" }, + { url = "https://files.pythonhosted.org/packages/76/d5/49b8720ec86f64e3dc3c804bd7e16fabb2a234a9a8b1b6753332ed343b4e/pyobjc_framework_coremidi-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:af3cdf195e8d5e30d1203889cc4107bebc6eb901aaa81bf3faf15e9ffaca0735", size = 24282, upload-time = "2025-11-14T09:45:32.288Z" }, ] [[package]] name = "pyobjc-framework-coreml" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/a0/875b5174794c984df60944be54df0282945f8bae4a606fbafa0c6b717ddd/pyobjc_framework_coreml-12.0.tar.gz", hash = "sha256:e1d7a9812886150881c86000fba885cb15201352c75fb286bd9e3a1819b5a4d5", size = 40814, upload-time = "2025-10-21T08:31:53.83Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/2d/baa9ea02cbb1c200683cb7273b69b4bee5070e86f2060b77e6a27c2a9d7e/pyobjc_framework_coreml-12.1.tar.gz", hash = "sha256:0d1a4216891a18775c9e0170d908714c18e4f53f9dc79fb0f5263b2aa81609ba", size = 40465, upload-time = "2025-11-14T10:14:02.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/3e/00e55a82f71da860b784ab19f06927af2e2f0e705ce57529239005b5cd7a/pyobjc_framework_coreml-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:410fa327fc5ba347ac6168c3f7a188f36c1c6966bef6b46f12543e8c4c9c26d9", size = 11344, upload-time = "2025-10-21T08:03:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/0f/f55369da4a33cfe1db38a3512aac4487602783d3a1d572d2c8c4ccce6abc/pyobjc_framework_coreml-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:16dafcfb123f022e62f47a590a7eccf7d0cb5957a77fd5f062b5ee751cb5a423", size = 11331, upload-time = "2025-11-14T09:45:50.445Z" }, ] [[package]] name = "pyobjc-framework-coremotion" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/15/d4bff65f1817a4be08c8dc572e40afb561394f6b98833cc1bd0799939fe4/pyobjc_framework_coremotion-12.0.tar.gz", hash = "sha256:7db1f7a5d1a29c631e000bdcf3500af9cc9d51eb140326ab8dc4aea0f4ea358a", size = 34231, upload-time = "2025-10-21T08:31:56.821Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/eb/abef7d405670cf9c844befc2330a46ee59f6ff7bac6f199bf249561a2ca6/pyobjc_framework_coremotion-12.1.tar.gz", hash = "sha256:8e1b094d34084cc8cf07bedc0630b4ee7f32b0215011f79c9e3cd09d205a27c7", size = 33851, upload-time = "2025-11-14T10:14:05.619Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/82/377885eb18ef3da482cfc35b7c0b45494669d320e00d3ff568dd9110e7f4/pyobjc_framework_coremotion-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d88f0733f9038741d77bceb920989e36f93c594b66b7f227afeca58d863b561", size = 10392, upload-time = "2025-10-21T08:04:00.976Z" }, + { url = "https://files.pythonhosted.org/packages/77/fd/0d24796779e4d8187abbce5d06cfd7614496d57a68081c5ff1e978b398f9/pyobjc_framework_coremotion-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed8cb67927985d97b1dd23ab6a4a1b716fc7c409c35349816108781efdcbb5b6", size = 10382, upload-time = "2025-11-14T09:46:03.438Z" }, ] [[package]] name = "pyobjc-framework-coreservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-fsevents" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/8e/e9ad1d201482036d528a9d9f18459706013f8e0f44a61b029d3164167584/pyobjc_framework_coreservices-12.0.tar.gz", hash = "sha256:36e0cb684d20c2ace81fde9829fd972a69463c51800fc1102a28118bfb804a0b", size = 366603, upload-time = "2025-10-21T08:32:20.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b3/52338a3ff41713f7d7bccaf63bef4ba4a8f2ce0c7eaff39a3629d022a79a/pyobjc_framework_coreservices-12.1.tar.gz", hash = "sha256:fc6a9f18fc6da64c166fe95f2defeb7ac8a9836b3b03bb6a891d36035260dbaa", size = 366150, upload-time = "2025-11-14T10:14:28.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/77/01a822a4f287a161a434e09d4abafcefd112f70f44193fdd1c85fac9a835/pyobjc_framework_coreservices-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:323c6facd66684c71b5df1cd911f4fe3a468218e83ed14c21be4e7f6c787e9a6", size = 30204, upload-time = "2025-10-21T08:04:15.938Z" }, + { url = "https://files.pythonhosted.org/packages/55/56/c905deb5ab6f7f758faac3f2cbc6f62fde89f8364837b626801bba0975c3/pyobjc_framework_coreservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b6ef07bcf99e941395491f1efcf46e99e5fb83eb6bfa12ae5371135d83f731e1", size = 30196, upload-time = "2025-11-14T09:46:19.356Z" }, ] [[package]] name = "pyobjc-framework-corespotlight" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/7e/6f7cd71fb6795eba72a5886b3de8a3ec2c3ae6f1696340d6e51076d48eaf/pyobjc_framework_corespotlight-12.0.tar.gz", hash = "sha256:440181b5bb177ed76cea6e5d65ed39814b04f51bcfa02fba1b58fb5dc30d17c9", size = 38429, upload-time = "2025-10-21T08:32:24.56Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/d0/88ca73b0cf23847af463334989dd8f98e44f801b811e7e1d8a5627ec20b4/pyobjc_framework_corespotlight-12.1.tar.gz", hash = "sha256:57add47380cd0bbb9793f50a4a4b435a90d4ebd2a33698e058cb353ddfb0d068", size = 38002, upload-time = "2025-11-14T10:14:31.948Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/fb/9a85e9c52b8fe75446f99faf9093555aa0198666051c9ddfb41a66fab6f8/pyobjc_framework_corespotlight-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1f5e2b003bd6bd6ece11f2d7366f11eef39decd79b2fcc4ef4624cce340a32b6", size = 9988, upload-time = "2025-10-21T08:04:35.511Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/1e7bacb9307a8df52234923e054b7303783e7a48a4637d44ce390b015921/pyobjc_framework_corespotlight-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:404a1e362fe19f0dff477edc1665d8ad90aada928246802da777399f7c06b22e", size = 9976, upload-time = "2025-11-14T09:46:45.221Z" }, ] [[package]] name = "pyobjc-framework-coretext" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/36/32ec183e555b73152d7813f6f7c277fd018440f70a1f142bd75b04946089/pyobjc_framework_coretext-12.0.tar.gz", hash = "sha256:8cc0c7dd2b7e68ad1c760784e422722550c77cbdbd60eb455170ec444ca1cfd2", size = 90546, upload-time = "2025-10-21T08:32:31.291Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b2/55fd3dce67223e799d862a62f2b8228836e3921dbf58a2fba939ecf605e1/pyobjc_framework_coretext-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:681b6276e1b14b79a8de2ba25dd2406fa88b147a55775e19bf0a2dd32f23c143", size = 30001, upload-time = "2025-10-21T08:04:51.101Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/7b8efc41e743adfa2d74b92dec263c91bcebfb188d2a8f5eea1886a195ff/pyobjc_framework_coretext-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f6742ba5b0bb7629c345e99eff928fbfd9e9d3d667421ac1a2a43bdb7ba9833", size = 29990, upload-time = "2025-11-14T09:47:01.206Z" }, ] [[package]] name = "pyobjc-framework-corewlan" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/06/ed26dab70dce1e2137e08cd18beca9313bccb2cc357bcbf5764c776b85ff/pyobjc_framework_corewlan-12.0.tar.gz", hash = "sha256:a724959e0b9b0fcc7b698b7c0a6e8457b82828c3a88385c9ac8c758791aed15a", size = 32760, upload-time = "2025-10-21T08:32:34.626Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/739a5d023566b506b3fd3d2412983faa95a8c16226c0dcd0f67a9294a342/pyobjc_framework_corewlan-12.1.tar.gz", hash = "sha256:a9d82ec71ef61f37e1d611caf51a4203f3dbd8caf827e98128a1afaa0fd2feb5", size = 32417, upload-time = "2025-11-14T10:14:41.921Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/9b/24bbc483ea6471d3d9321f3e768cd5399c5d41ab7a700a81114b120bd89d/pyobjc_framework_corewlan-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d9180f71c2169c8530c3592b5ab8809fbc93ed1d3526e26443fe927784aad259", size = 9942, upload-time = "2025-10-21T08:05:10.538Z" }, + { url = "https://files.pythonhosted.org/packages/f5/74/4d8a52b930a276f6f9b4f3b1e07cd518cb6d923cb512e39c935e3adb0b86/pyobjc_framework_corewlan-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3e3f2614eb37dfd6860d6a0683877c2f3b909758ef78b68e5f6b7ea9c858cc51", size = 9931, upload-time = "2025-11-14T09:47:20.849Z" }, ] [[package]] name = "pyobjc-framework-cryptotokenkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/31141f2f8ba250d1de21895984b179ca2307870a5c00e97f0ad34227303c/pyobjc_framework_cryptotokenkit-12.0.tar.gz", hash = "sha256:3b6aa22c584a5e330be6c85ca588798686c7eb3e25f06e069c12e82eacb36c38", size = 33086, upload-time = "2025-10-21T08:32:37.683Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/7c/d03ff4f74054578577296f33bc669fce16c7827eb1a553bb372b5aab30ca/pyobjc_framework_cryptotokenkit-12.1.tar.gz", hash = "sha256:c95116b4b7a41bf5b54aff823a4ef6f4d9da4d0441996d6d2c115026a42d82f5", size = 32716, upload-time = "2025-11-14T10:14:45.024Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/5e/488baba13dc3dc3b66ff009e492436f81c4282e038070950ac7c46f3d9e1/pyobjc_framework_cryptotokenkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bacf606c2a322fa3d7d9bfc0a9ae653a85450308073ff19d3e09b3c6b4bd1c2a", size = 12605, upload-time = "2025-10-21T08:05:22.903Z" }, + { url = "https://files.pythonhosted.org/packages/2c/90/1623b60d6189db08f642777374fd32287b06932c51dfeb1e9ed5bbf67f35/pyobjc_framework_cryptotokenkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d84b75569054fa0886e3e341c00d7179d5fe287e6d1509630dd698ee60ec5af1", size = 12598, upload-time = "2025-11-14T09:47:33.798Z" }, ] [[package]] name = "pyobjc-framework-datadetection" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/a1/2d556dd61c05f8fdd05d3383eb85f49d037cb3ccc276da10d38c86259720/pyobjc_framework_datadetection-12.0.tar.gz", hash = "sha256:3784ce6f220dc1bd7bc39fed240431500f106d4ae627ff2b99575ef7667f2a37", size = 12377, upload-time = "2025-10-21T08:32:39.458Z" } +sdist = { url = "https://files.pythonhosted.org/packages/db/97/9b03832695ec4d3008e6150ddfdc581b0fda559d9709a98b62815581259a/pyobjc_framework_datadetection-12.1.tar.gz", hash = "sha256:95539e46d3bc970ce890aa4a97515db10b2690597c5dd362996794572e5d5de0", size = 12323, upload-time = "2025-11-14T10:14:46.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/1d/5fa176aa5734c99ed0c99c64b547225ac97f6254ce00703d13289f09b4f2/pyobjc_framework_datadetection-12.0-py2.py3-none-any.whl", hash = "sha256:6715d68cb38a3660e083fb8c70bce75c30e61d91cd7818f006b6e2cb49491e05", size = 3505, upload-time = "2025-10-21T08:05:35.095Z" }, + { url = "https://files.pythonhosted.org/packages/70/1c/5d2f941501e84da8fef8ef3fd378b5c083f063f083f97dd3e8a07f0404b3/pyobjc_framework_datadetection-12.1-py2.py3-none-any.whl", hash = "sha256:4dc8e1d386d655b44b2681a4a2341fb2fc9addbf3dda14cb1553cd22be6a5387", size = 3497, upload-time = "2025-11-14T09:47:45.826Z" }, ] [[package]] name = "pyobjc-framework-devicecheck" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/56/72626225f821c6c7aef0bb14100e5418b9c4a46c101236336096e9f9b2ad/pyobjc_framework_devicecheck-12.0.tar.gz", hash = "sha256:dc51a4ac7afb68f7dbfaa6ec74b85ac0915058be9d4ee5e17b2ca33edde57d28", size = 12953, upload-time = "2025-10-21T08:32:41.158Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/af/c676107c40d51f55d0a42043865d7246db821d01241b518ea1d3b3ef1394/pyobjc_framework_devicecheck-12.1.tar.gz", hash = "sha256:567e85fc1f567b3fe64ac1cdc323d989509331f64ee54fbcbde2001aec5adbdb", size = 12885, upload-time = "2025-11-14T10:14:48.804Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/31/ee708c5f5329da63ad4448eed9079c4310c140a0d064cce9a03bb8c112e4/pyobjc_framework_devicecheck-12.0-py2.py3-none-any.whl", hash = "sha256:b11efc8d82875de368cd102aedea468da32fed6d0686b5da2eeed9cd750cc5ae", size = 3696, upload-time = "2025-10-21T08:05:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d8/1f1b13fa4775b6474c9ad0f4b823953eaeb6c11bd6f03fa8479429b36577/pyobjc_framework_devicecheck-12.1-py2.py3-none-any.whl", hash = "sha256:ffd58148bdef4a1ee8548b243861b7d97a686e73808ca0efac5bef3c430e4a15", size = 3684, upload-time = "2025-11-14T09:47:47.25Z" }, ] [[package]] name = "pyobjc-framework-devicediscoveryextension" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/b4/7fd6b558a657d1557ce41be0f647473f739079a6f5e1289cdd788fb717e0/pyobjc_framework_devicediscoveryextension-12.0.tar.gz", hash = "sha256:77a6a39468a9aa01d127b14ea314870b757280ddd802e7b30274ffc138b7a76c", size = 14768, upload-time = "2025-10-21T08:32:43.055Z" } +sdist = { url = "https://files.pythonhosted.org/packages/91/b0/e6e2ed6a7f4b689746818000a003ff7ab9c10945df66398ae8d323ae9579/pyobjc_framework_devicediscoveryextension-12.1.tar.gz", hash = "sha256:60e12445fad97ff1f83472255c943685a8f3a9d95b3126d887cfe769b7261044", size = 14718, upload-time = "2025-11-14T10:14:50.723Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/a5/b48b9018ebaf3d79ed01c33ba23828a2c10ad276f45457c7b5dd0b00ecd7/pyobjc_framework_devicediscoveryextension-12.0-py2.py3-none-any.whl", hash = "sha256:46c1a39be20183776ee95cc7b2132e2e3013aeea559ec0431275a77a613c4012", size = 4327, upload-time = "2025-10-21T08:05:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/005fe8db1e19135f493a3de8c8d38031e1ad2d626de4ef89f282acf4aff7/pyobjc_framework_devicediscoveryextension-12.1-py2.py3-none-any.whl", hash = "sha256:d6d6b606d27d4d88efc0bed4727c375e749149b360290c3ad2afc52337739a1b", size = 4321, upload-time = "2025-11-14T09:47:48.78Z" }, ] [[package]] name = "pyobjc-framework-dictionaryservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-coreservices" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/14/18a56b54e3fe6477f6a9ab92a318f05fd70b0b7797f4170bcd38418aba37/pyobjc_framework_dictionaryservices-12.0.tar.gz", hash = "sha256:e415dcdcc93ab42bc7beaab9b6696f6c417e57ace689d3e7d7ed9b1fef5d1119", size = 10589, upload-time = "2025-10-21T08:32:44.649Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/c0/daf03cdaf6d4e04e0cf164db358378c07facd21e4e3f8622505d72573e2c/pyobjc_framework_dictionaryservices-12.1.tar.gz", hash = "sha256:354158f3c55d66681fa903c7b3cb05a435b717fa78d0cef44d258d61156454a7", size = 10573, upload-time = "2025-11-14T10:14:53.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/b0/c57721118d28a9cd3d05fb74774c72eb2304b95a2a7beb1d7653fdd551e6/pyobjc_framework_dictionaryservices-12.0-py2.py3-none-any.whl", hash = "sha256:f8f54b290772c36081d38dfc089d5ed5c4486a7a584a7e1f685203e1c8b210f6", size = 3940, upload-time = "2025-10-21T08:05:39.627Z" }, + { url = "https://files.pythonhosted.org/packages/e7/13/ab308e934146cfd54691ddad87e572cd1edb6659d795903c4c75904e2d7d/pyobjc_framework_dictionaryservices-12.1-py2.py3-none-any.whl", hash = "sha256:578854eec17fa473ac17ab30050a7bbb2ab69f17c5c49b673695254c3e88ad4b", size = 3930, upload-time = "2025-11-14T09:47:50.782Z" }, ] [[package]] name = "pyobjc-framework-discrecording" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8c/ab/a6126d2a23e50cb5c53a731a4eb084b98c9ee7fc86ba3952a61ef1729c39/pyobjc_framework_discrecording-12.0.tar.gz", hash = "sha256:cb2bc1c9ea9c4f3ed38e4fa64ed0d7ff3c1d8cfa2a90cee5680e9468190aeb17", size = 55974, upload-time = "2025-10-21T08:32:49.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/87/8bd4544793bfcdf507174abddd02b1f077b48fab0004b3db9a63142ce7e9/pyobjc_framework_discrecording-12.1.tar.gz", hash = "sha256:6defc8ea97fb33b4d43870c673710c04c3dc48be30cdf78ba28191a922094990", size = 55607, upload-time = "2025-11-14T10:14:58.276Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/fb/946cdb1c70df944d5fd6e28c300f15c8672c4ef74f30b4a578deba09749c/pyobjc_framework_discrecording-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8ece9ff8b81c6ca1ab1360e7052346dfffa752f494edbe701d25f2312629f084", size = 14560, upload-time = "2025-10-21T08:05:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/0e/ce/89df4d53a0a5e3a590d6e735eca4f0ba4d1ccc0e0acfbc14164026a3c502/pyobjc_framework_discrecording-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f7d815f28f781e20de0bf278aaa10b0de7e5ea1189aa17676c0bf5b99e9e0d52", size = 14540, upload-time = "2025-11-14T09:47:55.442Z" }, ] [[package]] name = "pyobjc-framework-discrecordingui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-discrecording" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/12/895107bac87ad78c822debb9c68bfc17d7e632f9778cfb8f01b3b7fcafc8/pyobjc_framework_discrecordingui-12.0.tar.gz", hash = "sha256:31d31a903f4d12753e24e77951fe1fc2e27a7bf8643e7b97ba061d41008336ec", size = 16477, upload-time = "2025-10-21T08:32:51.288Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/63/8667f5bb1ecb556add04e86b278cb358dc1f2f03862705cae6f09097464c/pyobjc_framework_discrecordingui-12.1.tar.gz", hash = "sha256:6793d4a1a7f3219d063f39d87f1d4ebbbb3347e35d09194a193cfe16cba718a8", size = 16450, upload-time = "2025-11-14T10:15:00.254Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ce/35f69d7fb296e7548d2d76de446e02c351890a745799454e85bd170c60ca/pyobjc_framework_discrecordingui-12.0-py2.py3-none-any.whl", hash = "sha256:3cce85f3d13f28561e734b61facc1a16b632b73e69c5f14943816cf0fa184cdc", size = 4716, upload-time = "2025-10-21T08:05:55.284Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4e/76016130c27b98943c5758a05beab3ba1bc9349ee881e1dfc509ea954233/pyobjc_framework_discrecordingui-12.1-py2.py3-none-any.whl", hash = "sha256:6544ef99cad3dee95716c83cb207088768b6ecd3de178f7e1b17df5997689dfd", size = 4702, upload-time = "2025-11-14T09:48:08.01Z" }, ] [[package]] name = "pyobjc-framework-diskarbitration" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/96/be0ced457c9483efa7ec9789abcd5945446bc54ab1d785363c5f8d8bbd45/pyobjc_framework_diskarbitration-12.0.tar.gz", hash = "sha256:88df934c0cbc63daa496e2318e9ffa1d5e0096b6107fcff550afdd6817142813", size = 17191, upload-time = "2025-10-21T08:32:53.577Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/42/f75fcabec1a0033e4c5235cc8225773f610321d565b63bf982c10c6bbee4/pyobjc_framework_diskarbitration-12.1.tar.gz", hash = "sha256:6703bc5a09b38a720c9ffca356b58f7e99fa76fc988c9ec4d87112344e63dfc2", size = 17121, upload-time = "2025-11-14T10:15:02.223Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/9c/79e41d6fedea3c07d1a9d83b1d6ad2585a0d9693b57a8b92ee60a0c19135/pyobjc_framework_diskarbitration-12.0-py2.py3-none-any.whl", hash = "sha256:690e34ea7548c21519855e5d1ebb0fcf9538d7562ec15779c5c63b580d9c855f", size = 4889, upload-time = "2025-10-21T08:05:56.835Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/c1f54c47af17cb6b923eab85e95f22396c52f90ee8f5b387acffad9a99ea/pyobjc_framework_diskarbitration-12.1-py2.py3-none-any.whl", hash = "sha256:54caf3079fe4ae5ac14466a9b68923ee260a1a88a8290686b4a2015ba14c2db6", size = 4877, upload-time = "2025-11-14T09:48:09.945Z" }, ] [[package]] name = "pyobjc-framework-dvdplayback" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/28/a9b7a2722cf94382ec843601e656524246384f3ff710a60c18e617acc756/pyobjc_framework_dvdplayback-12.0.tar.gz", hash = "sha256:433e8790641a210304b47079965eda2737578033747f3eb20d1758afcfbb35a2", size = 32345, upload-time = "2025-10-21T08:32:56.597Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/dd/7859a58e8dd336c77f83feb76d502e9623c394ea09322e29a03f5bc04d32/pyobjc_framework_dvdplayback-12.1.tar.gz", hash = "sha256:279345d4b5fb2c47dd8e5c2fd289e644b6648b74f5c25079805eeb61bfc4a9cd", size = 32332, upload-time = "2025-11-14T10:15:05.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/81/57fe080195079c27e45bcfbc528895549f6f35080fb41dde6720485964ec/pyobjc_framework_dvdplayback-12.0-py2.py3-none-any.whl", hash = "sha256:9d68ed25523e14faf6c79f89d87c21942147063b7e5cb625edad40e9dffe6360", size = 8253, upload-time = "2025-10-21T08:05:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/22c07c28fab1f15f0d364806e39a6ca63c737c645fe7e98e157878b5998c/pyobjc_framework_dvdplayback-12.1-py2.py3-none-any.whl", hash = "sha256:af911cc222272a55b46a1a02a46a355279aecfd8132231d8d1b279e252b8ad4c", size = 8243, upload-time = "2025-11-14T09:48:11.824Z" }, ] [[package]] name = "pyobjc-framework-eventkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/c4/b6e30b7917777bb74d3caffb6568e4644c0b9cfa75b0dfc4942bfde3fad1/pyobjc_framework_eventkit-12.0.tar.gz", hash = "sha256:6a67a70cee1d9399cca2c04303ec10ae0d2a99ceca1bd7f9a3c67ff166057680", size = 28578, upload-time = "2025-10-21T08:32:59.228Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/42/4ec97e641fdcf30896fe76476181622954cb017117b1429f634d24816711/pyobjc_framework_eventkit-12.1.tar.gz", hash = "sha256:7c1882be2f444b1d0f71e9a0cd1e9c04ad98e0261292ab741fc9de0b8bbbbae9", size = 28538, upload-time = "2025-11-14T10:15:07.878Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/49/aa23695c867aafea7254058218202bffda0abf1b3bbf2d1c617a73266662/pyobjc_framework_eventkit-12.0-py2.py3-none-any.whl", hash = "sha256:1771062ab40d26e878cbf27bdf1f9fe539854c62eea8b44d7be9218dc7d6ce67", size = 6827, upload-time = "2025-10-21T08:06:00.692Z" }, + { url = "https://files.pythonhosted.org/packages/f4/35/142f43227627d6324993869d354b9e57eb1e88c4e229e2271592254daf25/pyobjc_framework_eventkit-12.1-py2.py3-none-any.whl", hash = "sha256:3d2d36d5bd9e0a13887a6ac7cdd36675985ebe2a9cb3cdf8cec0725670c92c60", size = 6820, upload-time = "2025-11-14T09:48:14.035Z" }, ] [[package]] name = "pyobjc-framework-exceptionhandling" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/e6/afbd7407d43562878cf66f16bc79439616a447900f1dadf5015e9bbf3f8d/pyobjc_framework_exceptionhandling-12.0.tar.gz", hash = "sha256:047dc74c185b9bacb165a6d77a079a0ccec099f0ab516da726273305e41b18f6", size = 16748, upload-time = "2025-10-21T08:33:01.159Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/17/5c9d4164f7ccf6b9100be0ad597a7857395dd58ea492cba4f0e9c0b77049/pyobjc_framework_exceptionhandling-12.1.tar.gz", hash = "sha256:7f0719eeea6695197fce0e7042342daa462683dc466eb6a442aad897032ab00d", size = 16694, upload-time = "2025-11-14T10:15:10.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/c3/97804dc40a8a3af7a01b71b52a50bb2d43e4bb6aabb15a20de083f49caa6/pyobjc_framework_exceptionhandling-12.0-py2.py3-none-any.whl", hash = "sha256:d69f34caf50bd2fe135d04ffc00342e4b1c0d76340170418688317ad4685ac08", size = 7124, upload-time = "2025-10-21T08:06:02.731Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ad/8e05acf3635f20ea7d878be30d58a484c8b901a8552c501feb7893472f86/pyobjc_framework_exceptionhandling-12.1-py2.py3-none-any.whl", hash = "sha256:2f1eae14cf0162e53a0888d9ffe63f047501fe583a23cdc9c966e89f48cf4713", size = 7113, upload-time = "2025-11-14T09:48:15.685Z" }, ] [[package]] name = "pyobjc-framework-executionpolicy" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/40/10c3c6a10d0b2829e96fcf3f8375846e5af1926b9b024147c9fc7e0ceff8/pyobjc_framework_executionpolicy-12.0.tar.gz", hash = "sha256:508d1ac045f9f2747db1a93ce45381f4e5f64881f4adc79fb0474f4dbe6237eb", size = 12649, upload-time = "2025-10-21T08:33:03.053Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/11/db765e76e7b00e1521d7bb3a61ae49b59e7573ac108da174720e5d96b61b/pyobjc_framework_executionpolicy-12.1.tar.gz", hash = "sha256:682866589365cd01d3a724d8a2781794b5cba1e152411a58825ea52d7b972941", size = 12594, upload-time = "2025-11-14T10:15:12.077Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/67/b8398c778e3821f666d8530974e216f7e7c148beb5fa0088c151935b6554/pyobjc_framework_executionpolicy-12.0-py2.py3-none-any.whl", hash = "sha256:6b882acdbfe5cc6f0783f9f99ffb98d2d34eb72b0761e8cc812f7b518b77b2a8", size = 3749, upload-time = "2025-10-21T08:06:04.194Z" }, + { url = "https://files.pythonhosted.org/packages/51/2c/f10352398f10f244401ab8f53cabd127dc3f5dbbfc8de83464661d716671/pyobjc_framework_executionpolicy-12.1-py2.py3-none-any.whl", hash = "sha256:c3a9eca3bd143cf202787dd5e3f40d954c198f18a5e0b8b3e2fcdd317bf33a52", size = 3739, upload-time = "2025-11-14T09:48:17.35Z" }, ] [[package]] name = "pyobjc-framework-extensionkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/54/36ea7f32481e5e4cc1bac159ff9e4dc94fd4827f544e85caa2a03b4c5938/pyobjc_framework_extensionkit-12.0.tar.gz", hash = "sha256:02e6b5613797a79c77b277b352441c8667117b657b06b862277c681d75cc7c01", size = 19085, upload-time = "2025-10-21T08:33:05.427Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/d4/e9b1f74d29ad9dea3d60468d59b80e14ed3a19f9f7a25afcbc10d29c8a1e/pyobjc_framework_extensionkit-12.1.tar.gz", hash = "sha256:773987353e8aba04223dbba3149253db944abfb090c35318b3a770195b75da6d", size = 18694, upload-time = "2025-11-14T10:15:14.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/a2/4a280fc8c6df72b6a3ea83997251fd8bdc81c06cb09fc726b2d2c1000613/pyobjc_framework_extensionkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:83c4adb2a6dcc45666c08f0d9cfc9a6021786dfb247defea5366d0cdccb03544", size = 7924, upload-time = "2025-10-21T08:06:08.124Z" }, + { url = "https://files.pythonhosted.org/packages/4f/02/3d1df48f838dc9d64f03bedd29f0fdac6c31945251c9818c3e34083eb731/pyobjc_framework_extensionkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9139c064e1c7f21455411848eb39f092af6085a26cad322aa26309260e7929d9", size = 7919, upload-time = "2025-11-14T09:48:22.14Z" }, ] [[package]] name = "pyobjc-framework-externalaccessory" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/af/65fb12b47da17c7cbe32c5650fbe6071aa7ca580d1db27f6760730bbba55/pyobjc_framework_externalaccessory-12.0.tar.gz", hash = "sha256:654301eb0370eef57ddd472c8e71e25a0f0e6d720e38730369b1c3712fe67b0b", size = 21353, upload-time = "2025-10-21T08:33:07.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/35/86c097ae2fdf912c61c1276e80f3e090a3fc898c75effdf51d86afec456b/pyobjc_framework_externalaccessory-12.1.tar.gz", hash = "sha256:079f770a115d517a6ab87db1b8a62ca6cdf6c35ae65f45eecc21b491e78776c0", size = 20958, upload-time = "2025-11-14T10:15:16.419Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/7a/d90b0e09d784e18c5a3ea1530d234c225de758cb8bb24cb4e6882e8c9736/pyobjc_framework_externalaccessory-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:913b0e5ef1047ad87b6b5e690ac3dd7132f25c51874ba4552a57092d161374ab", size = 8919, upload-time = "2025-10-21T08:06:22.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/2a83b63e82ce58722277a00521c3aeec58ac5abb3086704554e47f8becf3/pyobjc_framework_externalaccessory-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:32208e05c9448c8f41b3efdd35dbea4a8b119af190f7a2db0d580be8a5cf962e", size = 8911, upload-time = "2025-11-14T09:48:35.349Z" }, ] [[package]] name = "pyobjc-framework-fileprovider" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/3c/57bcedb1076903d44078ecfa402ee4a27a3cee123a86e684c8683316b2d1/pyobjc_framework_fileprovider-12.0.tar.gz", hash = "sha256:8b0c33f34c123b757b09406e6fd29a8e5b3348cc8e271533386af860f2bfce65", size = 43431, upload-time = "2025-10-21T08:33:11.66Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/9a/724b1fae5709f8860f06a6a2a46de568f9bb8bdb2e2aae45b4e010368f51/pyobjc_framework_fileprovider-12.1.tar.gz", hash = "sha256:45034e0d00ae153c991aa01cb1fd41874650a30093e77ba73401dcce5534c8ad", size = 43071, upload-time = "2025-11-14T10:15:19.989Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/3b/0a439219ec7f71bad775481d4f943c1ac8eebe3d841938160049cbf55cb6/pyobjc_framework_fileprovider-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd2a7b6d79e3dd1487375c0f9a653b0242d5abe000915d443cc57ab384369f64", size = 20981, upload-time = "2025-10-21T08:06:35.412Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/2f56167e9f43d3b25a5ed073305ca0cfbfc66bedec7aae9e1f2c9c337265/pyobjc_framework_fileprovider-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d527c417f06d27c4908e51d4e6ccce0adcd80c054f19e709626e55c511dc963", size = 20970, upload-time = "2025-11-14T09:48:50.557Z" }, ] [[package]] name = "pyobjc-framework-fileproviderui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-fileprovider" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/19/fb3a1ce592110c02152b1663ce82ec9505af9310dc1b4d30b6669e2becdb/pyobjc_framework_fileproviderui-12.0.tar.gz", hash = "sha256:7d6903eeb9a1b890d26d4beff0fa027be780c2135eab6a642fbfdcad71dfa78c", size = 12476, upload-time = "2025-10-21T08:33:13.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/00/234f9b93f75255845df81d9d5ea20cb83ecb5c0a4e59147168b622dd0b9d/pyobjc_framework_fileproviderui-12.1.tar.gz", hash = "sha256:15296429d9db0955abc3242b2920b7a810509a85118dbc185f3ac8234e5a6165", size = 12437, upload-time = "2025-11-14T10:15:22.044Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/24/41981f2d97c7beeaf7b48351fc7044293f99ffd678c5690e24e356ce02f4/pyobjc_framework_fileproviderui-12.0-py2.py3-none-any.whl", hash = "sha256:821e5a84f6c2122cd03d64428a9b0af2d41ee27bce8b417d9fa7a97470a97ee7", size = 3723, upload-time = "2025-10-21T08:06:49.631Z" }, + { url = "https://files.pythonhosted.org/packages/e8/65/cc4397511bd0af91993d6302a2aed205296a9ad626146eefdfc8a9624219/pyobjc_framework_fileproviderui-12.1-py2.py3-none-any.whl", hash = "sha256:521a914055089e28631018bd78df4c4f7416e98b4150f861d4a5bc97d5b1ffe4", size = 3715, upload-time = "2025-11-14T09:49:04.213Z" }, ] [[package]] name = "pyobjc-framework-findersync" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/8f/7574edd92f3ba6358b14708ab40a049d2a4c02029ac6f4f88f498074a0ba/pyobjc_framework_findersync-12.0.tar.gz", hash = "sha256:7a7220395127bec31b4cbbbe40c1ec8fa0f5586c241e5c158c567543338d766d", size = 13615, upload-time = "2025-10-21T08:33:15.282Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/63/c8da472e0910238a905bc48620e005a1b8ae7921701408ca13e5fb0bfb4b/pyobjc_framework_findersync-12.1.tar.gz", hash = "sha256:c513104cef0013c233bf8655b527df665ce6f840c8bc0b3781e996933d4dcfa6", size = 13507, upload-time = "2025-11-14T10:15:24.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/93/b49eb8f4e8bdc8892018acfd82b0be9b5b4f2cc44416867bf3afa0e16ccc/pyobjc_framework_findersync-12.0-py2.py3-none-any.whl", hash = "sha256:0b27ef0255a04d0241700bd68d30df629c01a02afeb9ab2aad0bd50219022485", size = 4901, upload-time = "2025-10-21T08:06:51.271Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/ec7f393e3e2fd11cbdf930d884a0ba81078bdb61920b3cba4f264de8b446/pyobjc_framework_findersync-12.1-py2.py3-none-any.whl", hash = "sha256:e07abeca52c486cf14927f617afc27afa7a3828b99fab3ad02355105fb29203e", size = 4889, upload-time = "2025-11-14T09:49:05.763Z" }, ] [[package]] name = "pyobjc-framework-fsevents" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/2b/52f6c1f1c8725b08d53c8fe4c0ea18fb17a91674b8023e20d6aef0f15820/pyobjc_framework_fsevents-12.0.tar.gz", hash = "sha256:768bfc90da3547516b6833e33f28d5f49238c2b47f44b8a9b7c941b951488cd9", size = 26890, upload-time = "2025-10-21T08:33:18.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/17/21f45d2bca2efc72b975f2dfeae7a163dbeabb1236c1f188578403fd4f09/pyobjc_framework_fsevents-12.1.tar.gz", hash = "sha256:a22350e2aa789dec59b62da869c1b494a429f8c618854b1383d6473f4c065a02", size = 26487, upload-time = "2025-11-14T10:15:26.796Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/de/77ba26869434b6af5261a8da3d60633fa7529335e73efb46f6a8799c1f0e/pyobjc_framework_fsevents-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:72107b82442e644b603306ee65900cc5a25a941b3374c77c0f3c3db713cd442c", size = 13070, upload-time = "2025-10-21T08:06:55.91Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3f/a7fe5656b205ee3a9fd828e342157b91e643ee3e5c0d50b12bd4c737f683/pyobjc_framework_fsevents-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:459cc0aac9850c489d238ba778379d09f073bbc3626248855e78c4bc4d97fe46", size = 13059, upload-time = "2025-11-14T09:49:09.814Z" }, ] [[package]] name = "pyobjc-framework-fskit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/6e/240f3ff4e1b6c51ddb48f0ebb7dfb25d6d328b474fc43891fbbd70a7e760/pyobjc_framework_fskit-12.0.tar.gz", hash = "sha256:90efb6c61aa27f7a0c7a9c09d465f5dac65ccfc35753e772be0394274fbad499", size = 42767, upload-time = "2025-10-21T08:33:21.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/55/d00246d6e6d9756e129e1d94bc131c99eece2daa84b2696f6442b8a22177/pyobjc_framework_fskit-12.1.tar.gz", hash = "sha256:ec54e941cdb0b7d800616c06ca76a93685bd7119b8aa6eb4e7a3ee27658fc7ba", size = 42372, upload-time = "2025-11-14T10:15:30.411Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/1b/7d33b5645ab26f51a0e69c19649880021c6e45176bb9cf52df5f41703103/pyobjc_framework_fskit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:decb8b41bed5a66f0ee7d4786a93bf81a965edd2775e6850ad5d30af374e8364", size = 20234, upload-time = "2025-10-21T08:07:11.223Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/5a0b6b8dc18b9dbcb7d1ef7bebdd93f12560097dafa6d7c4b3c15649afba/pyobjc_framework_fskit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:95b9135eea81eeed319dcca32c9db04b38688301586180b86c4585fef6b0e9cd", size = 20228, upload-time = "2025-11-14T09:49:25.324Z" }, ] [[package]] name = "pyobjc-framework-gamecenter" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/46/f4a7d4aef99e82a65a6c769cf5eed4dad42c8a9a6b2bc72234590513990f/pyobjc_framework_gamecenter-12.0.tar.gz", hash = "sha256:c33467f4a8d93b1d6d3e719d6d11d373909ede6e86f61eaf5fa936d8d7e78cdf", size = 31860, upload-time = "2025-10-21T08:33:25.12Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/f8/b5fd86f6b722d4259228922e125b50e0a6975120a1c4d957e990fb84e42c/pyobjc_framework_gamecenter-12.1.tar.gz", hash = "sha256:de4118f14c9cf93eb0316d49da410faded3609ce9cd63425e9ef878cebb7ea72", size = 31473, upload-time = "2025-11-14T10:15:33.38Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/0a/8b38d1d2ce1866ad6236d26762cc9ad75191381f151d917a8ec14de3c6c1/pyobjc_framework_gamecenter-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e2307e623f97228e3880c8315e9f5b536fbc0f78bba36197888e56c1286c7dc", size = 18829, upload-time = "2025-10-21T08:07:27.153Z" }, + { url = "https://files.pythonhosted.org/packages/ca/17/6491f9e96664e05ec00af7942a6c2f69217771522c9d1180524273cac7cb/pyobjc_framework_gamecenter-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:30943512f2aa8cb129f8e1abf951bf06922ca20b868e918b26c19202f4ee5cc4", size = 18824, upload-time = "2025-11-14T09:49:42.543Z" }, ] [[package]] name = "pyobjc-framework-gamecontroller" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/f2496dbe861fff298f6f7d40f2aff085d04704afd87320fcf11227397efd/pyobjc_framework_gamecontroller-12.0.tar.gz", hash = "sha256:d01ede48c35ae62b27db500218a7c83b80a876c0ec2ac42c365f9b8e711fc8e2", size = 54982, upload-time = "2025-10-21T08:33:29.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/14/353bb1fe448cd833839fd199ab26426c0248088753e63c22fe19dc07530f/pyobjc_framework_gamecontroller-12.1.tar.gz", hash = "sha256:64ed3cc4844b67f1faeb540c7cc8d512c84f70b3a4bafdb33d4663a2b2a2b1d8", size = 54554, upload-time = "2025-11-14T10:15:37.591Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/06/5023f57029180f625c2f7c837c826a61a49a9aa0088e154f343e64a3a957/pyobjc_framework_gamecontroller-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c1eadf51b2cfd9aed746d90e8d2d4eded32d3f6a06f5459daa4a1fd65ebd96fa", size = 20918, upload-time = "2025-10-21T08:07:44.73Z" }, + { url = "https://files.pythonhosted.org/packages/e4/dc/1d8bd4845a46cb5a5c1f860d85394e64729b2447bbe149bb33301bc99056/pyobjc_framework_gamecontroller-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2633c2703fb30ce068b2f5ce145edbd10fd574d2670b5cdee77a9a126f154fec", size = 20913, upload-time = "2025-11-14T09:49:58.863Z" }, ] [[package]] name = "pyobjc-framework-gamekit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/aa/2734bdd000970d8884a77714c5adebba684c982821f9293205e2cb71b429/pyobjc_framework_gamekit-12.0.tar.gz", hash = "sha256:381724769aa57428eefdb11f1fae9cf6933061723a5806ac41dc63553850f18c", size = 64236, upload-time = "2025-10-21T08:33:34.51Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/7b/d625c0937557f7e2e64200fdbeb867d2f6f86b2f148b8d6bfe085e32d872/pyobjc_framework_gamekit-12.1.tar.gz", hash = "sha256:014d032c3484093f1409f8f631ba8a0fd2ff7a3ae23fd9d14235340889854c16", size = 63833, upload-time = "2025-11-14T10:15:42.842Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/b1/6c5a4a147605bb6563c35487fa08bdb9ce9fa6223ed8bfe6df9af277c973/pyobjc_framework_gamekit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:21f13014588ff9f1e9c680ff602d50f021a25017825e6101a53be15ea27a547e", size = 22468, upload-time = "2025-10-21T08:08:04.598Z" }, + { url = "https://files.pythonhosted.org/packages/06/47/d3b78cf57bc2d62dc1408aaad226b776d167832063bbaa0c7cc7a9a6fa12/pyobjc_framework_gamekit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb263e90a6af3d7294bc1b1ea5907f8e33bb77d62fb806696f8df7e14806ccad", size = 22463, upload-time = "2025-11-14T09:50:16.444Z" }, ] [[package]] name = "pyobjc-framework-gameplaykit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-spritekit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/d9/d506dde3818c09295f11af52176cf3a6a5d00333cea19069ff44c44a4a89/pyobjc_framework_gameplaykit-12.0.tar.gz", hash = "sha256:e0ff1cac933f5686b62c06766fca7e740932d93fb7e1367e18ab3be082a810dc", size = 41918, upload-time = "2025-10-21T08:33:38.116Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/11/c310bbc2526f95cce662cc1f1359bb11e2458eab0689737b4850d0f6acb7/pyobjc_framework_gameplaykit-12.1.tar.gz", hash = "sha256:935ebd806d802888969357946245d35a304c530c86f1ffe584e2cf21f0a608a8", size = 41511, upload-time = "2025-11-14T10:15:46.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/31/03e40bc9896c367f08cf220f740e47225beaeca35d4845abe98e67cb5b12/pyobjc_framework_gameplaykit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ca24ed4b4f791751799c25b8288b498c2702e9b2d38ee8884ef10f9da96d2f0", size = 13136, upload-time = "2025-10-21T08:08:22.412Z" }, + { url = "https://files.pythonhosted.org/packages/3b/84/7a4a2c358770f5ffdb6bdabb74dcefdfa248b17c250a7c0f9d16d3b8d987/pyobjc_framework_gameplaykit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b2fb27f9f48c3279937e938a0456a5231b5c89e53e3199b9d54009a0bbd1228a", size = 13125, upload-time = "2025-11-14T09:50:34.384Z" }, ] [[package]] name = "pyobjc-framework-gamesave" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/b6/de69ddc08ea89a6e2dc3cb64b0ba468996b43b6d91e65463d66530f1cef6/pyobjc_framework_gamesave-12.0.tar.gz", hash = "sha256:2412a243b7a06afa08c46003bbe75790d8cfae2761f55187dd54b082da7ca62f", size = 12714, upload-time = "2025-10-21T08:33:40.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/1f/8d05585c844535e75dbc242dd6bdfecfc613d074dcb700362d1c908fb403/pyobjc_framework_gamesave-12.1.tar.gz", hash = "sha256:eb731c97aa644e78a87838ed56d0e5bdbaae125bdc8854a7772394877312cc2e", size = 12654, upload-time = "2025-11-14T10:15:48.344Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/84/27dab140da6102f23f1666630d876446152e1d28b35920e65797496d4222/pyobjc_framework_gamesave-12.0-py2.py3-none-any.whl", hash = "sha256:a5be943b5969848b44d2132e33ed88720aa4c389916e41f909e3a7a144ea71cf", size = 3697, upload-time = "2025-10-21T08:08:33.335Z" }, + { url = "https://files.pythonhosted.org/packages/59/ec/93d48cb048a1b35cea559cc9261b07f0d410078b3af029121302faa410d0/pyobjc_framework_gamesave-12.1-py2.py3-none-any.whl", hash = "sha256:432e69f8404be9290d42c89caba241a3156ed52013947978ac54f0f032a14ffd", size = 3689, upload-time = "2025-11-14T09:50:47.263Z" }, ] [[package]] name = "pyobjc-framework-healthkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/8c/12fa3d73598d80f2ce77bc0ab1a344e89fd8b5db93a36c74e1c925cf632a/pyobjc_framework_healthkit-12.0.tar.gz", hash = "sha256:4e47b84ed39f322e90a45d39eb91ddcde9fffbf76c75b6e700b80258db3ec58b", size = 92173, upload-time = "2025-10-21T08:33:46.835Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/67/436630d00ba1028ea33cc9df2fc28e081481433e5075600f2ea1ff00f45e/pyobjc_framework_healthkit-12.1.tar.gz", hash = "sha256:29c5e5de54b41080b7a4b0207698ac6f600dcb9149becc9c6b3a69957e200e5c", size = 91802, upload-time = "2025-11-14T10:15:54.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/c0/915497d4e19c07ac14d36fb9ca333b79dc7f7309bac056e143defdeaee35/pyobjc_framework_healthkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b16f091a36a4606023e7f69758406bb08c2c66d8157ae04f011e3e054d0d4ea", size = 20797, upload-time = "2025-10-21T08:08:38.665Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/b23d3c04ee37cbb94ff92caedc3669cd259be0344fcf6bdf1ff75ff0a078/pyobjc_framework_healthkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e67bce41f8f63c11000394c6ce1dc694655d9ff0458771340d2c782f9eafcc6e", size = 20785, upload-time = "2025-11-14T09:50:52.152Z" }, ] [[package]] name = "pyobjc-framework-imagecapturecore" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/a7/52fa4a0092feaa2c0b72256b3593e03028a8e491344e64c074bdbf33d926/pyobjc_framework_imagecapturecore-12.0.tar.gz", hash = "sha256:36d12a818660de257635b338f286083d09a5b34e4ebd3bc6aae4b979028585cd", size = 46807, upload-time = "2025-10-21T08:33:51.102Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/a1/39347381fc7d3cd5ab942d86af347b25c73f0ddf6f5227d8b4d8f5328016/pyobjc_framework_imagecapturecore-12.1.tar.gz", hash = "sha256:c4776c59f4db57727389d17e1ffd9c567b854b8db52198b3ccc11281711074e5", size = 46397, upload-time = "2025-11-14T10:15:58.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/0d/8fc4d7fe9f2bb48748355c7ab87a2e12acfbc715f6a9fadec57ed1e854aa/pyobjc_framework_imagecapturecore-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:42610501ebd9671c11a2dddbb06501fe2c79b35536c90d0854eb543568d4f259", size = 15993, upload-time = "2025-10-21T08:08:54.39Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6b/b34d5c9041e90b8a82d87025a1854b60a8ec2d88d9ef9e715f3a40109ed5/pyobjc_framework_imagecapturecore-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:64d1eb677fe5b658a6b6ed734b7120998ea738ca038ec18c4f9c776e90bd9402", size = 15983, upload-time = "2025-11-14T09:51:09.978Z" }, ] [[package]] name = "pyobjc-framework-inputmethodkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/49/c58dc9dd9dfce812cadcafb1da8bed88af88fe6f10978a0522ab4b96ceb5/pyobjc_framework_inputmethodkit-12.0.tar.gz", hash = "sha256:a5c16a003f0a08e7ac005a6c4d43074bb5e4cf587d5e57a4f11c47232349962d", size = 23449, upload-time = "2025-10-21T08:33:53.964Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/b8/d33dd8b7306029bbbd80525bf833fc547e6a223c494bf69a534487283a28/pyobjc_framework_inputmethodkit-12.1.tar.gz", hash = "sha256:f63b6fe2fa7f1412eae63baea1e120e7865e3b68ccfb7d8b0a4aadb309f2b278", size = 23054, upload-time = "2025-11-14T10:16:01.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/36/7b8be5c8202cb3e184542dd72dcee00cf446ecc14327851630cd4cf30db3/pyobjc_framework_inputmethodkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:95194c1df58d683cf677eb160c134140e93e398c43b9c0d03b0e764f9cf79544", size = 9512, upload-time = "2025-10-21T08:09:08.825Z" }, + { url = "https://files.pythonhosted.org/packages/a7/04/1315f84dba5704a4976ea0185f877f0f33f28781473a817010cee209a8f0/pyobjc_framework_inputmethodkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4e02f49816799a31d558866492048d69e8086178770b76f4c511295610e02ab", size = 9502, upload-time = "2025-11-14T09:51:24.708Z" }, ] [[package]] name = "pyobjc-framework-installerplugins" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/65/403d3d6244f8e85201b232b37aacde4d6e80895b7d709047ce71b3f5e830/pyobjc_framework_installerplugins-12.0.tar.gz", hash = "sha256:fbd5824e282f95999ae14b0128ad7bc3dad4b44a067016a8e3750f0252f4d6b7", size = 25313, upload-time = "2025-10-21T08:33:56.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/60/ca4ab04eafa388a97a521db7d60a812e2f81a3c21c2372587872e6b074f9/pyobjc_framework_installerplugins-12.1.tar.gz", hash = "sha256:1329a193bd2e92a2320a981a9a421a9b99749bade3e5914358923e94fe995795", size = 25277, upload-time = "2025-11-14T10:16:04.379Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/d5/be8217352ebb3d78b600bd85fe274f44f642fd8268b3bca4335caaa7da85/pyobjc_framework_installerplugins-12.0-py2.py3-none-any.whl", hash = "sha256:60950cc9dd4fd0f5e4e8d4cbcf3197765f20b390a8fbfd91478c955e6d90ba11", size = 4826, upload-time = "2025-10-21T08:09:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/31dca45db3342882a628aa1b27707a283d4dc7ef558fddd2533175a0661a/pyobjc_framework_installerplugins-12.1-py2.py3-none-any.whl", hash = "sha256:d2201c81b05bdbe0abf0af25db58dc230802573463bea322f8b2863e37b511d5", size = 4813, upload-time = "2025-11-14T09:51:37.836Z" }, ] [[package]] name = "pyobjc-framework-instantmessage" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/e4/fe583666b7f99aa14d8656600823668d008f52ccce0476c0c9ab2d2ada46/pyobjc_framework_instantmessage-12.0.tar.gz", hash = "sha256:8a9fa19a03c6c56a4e366422259d46a5462ddee23acdb44e74f71e3f923e1aa5", size = 31255, upload-time = "2025-10-21T08:33:59.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/67/66754e0d26320ba24a33608ca94d3f38e60ee6b2d2e094cb6269b346fdd4/pyobjc_framework_instantmessage-12.1.tar.gz", hash = "sha256:f453118d5693dc3c94554791bd2aaafe32a8b03b0e3d8ec3934b44b7fdd1f7e7", size = 31217, upload-time = "2025-11-14T10:16:07.693Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0e/0e768739befaffe849d1b3aaf2b7078c04d6b2b3e14fb37c53b44c09a291/pyobjc_framework_instantmessage-12.0-py2.py3-none-any.whl", hash = "sha256:9b0068f669e735f59b5d5ccb44861275530cb4bc4aca5e1fd7179828a23f500d", size = 5446, upload-time = "2025-10-21T08:09:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/c1/38/6ae95b5c87d887c075bd5f4f7cca3d21dafd0a77cfdde870e87ca17579eb/pyobjc_framework_instantmessage-12.1-py2.py3-none-any.whl", hash = "sha256:cd91d38e8f356afd726b6ea8c235699316ea90edfd3472965c251efbf4150bc9", size = 5436, upload-time = "2025-11-14T09:51:39.557Z" }, ] [[package]] name = "pyobjc-framework-intents" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/b6/d2692a8710a9c2c605f8449c90d38cb454ec5e4d35731a97beceed1051f2/pyobjc_framework_intents-12.0.tar.gz", hash = "sha256:77e778574911fe4db80256094260f959c60ad9d67f9cd3d34c136fc37700bba2", size = 132672, upload-time = "2025-10-21T08:34:08.981Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/a1/3bab6139e94b97eca098e1562f5d6840e3ff10ea1f7fd704a17111a97d5b/pyobjc_framework_intents-12.1.tar.gz", hash = "sha256:bd688c3ab34a18412f56e459e9dae29e1f4152d3c2048fcacdef5fc49dfb9765", size = 132262, upload-time = "2025-11-14T10:16:16.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/4e/dcdcdfd8a09c9fa6cd2574ccc1475eedce832c7bfe2981d2c8a8e0eb7e09/pyobjc_framework_intents-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2b97a3bbf9dd987a0441028e58a0ba6a95772c41a72347f0c27ebd857e20225", size = 32144, upload-time = "2025-10-21T08:09:26.908Z" }, + { url = "https://files.pythonhosted.org/packages/d0/25/648db47b9c3879fa50c65ab7cc5fbe0dd400cc97141ac2658ef2e196c0b6/pyobjc_framework_intents-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dc68dc49f1f8d9f8d2ffbc0f57ad25caac35312ddc444899707461e596024fec", size = 32134, upload-time = "2025-11-14T09:51:46.369Z" }, ] [[package]] name = "pyobjc-framework-intentsui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-intents" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/1c/ac36510c5697d930e5922ae70c141c34b0bd9185e1ca71f8de0a8a9025da/pyobjc_framework_intentsui-12.0.tar.gz", hash = "sha256:cb53f34abef6a96f1df12b34c682088578fbc3e1f63d0ee02e09f41f16fb54a8", size = 20142, upload-time = "2025-10-21T08:34:11.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/cf/f0e385b9cfbf153d68efe8d19e5ae672b59acbbfc1f9b58faaefc5ec8c9e/pyobjc_framework_intentsui-12.1.tar.gz", hash = "sha256:16bdf4b7b91c0d1ec9d5513a1182861f1b5b7af95d4f4218ff7cf03032d57f99", size = 19784, upload-time = "2025-11-14T10:16:18.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/ea/cfd64403776dca3fa53ea268dc80a4840c83bc517a01cb4a9f29f6bea816/pyobjc_framework_intentsui-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a3f25724f442cb5f8113d7e4db15e612c27b8c6a7c68b0db8f2a27f16ac6ea04", size = 8971, upload-time = "2025-10-21T08:09:47.323Z" }, + { url = "https://files.pythonhosted.org/packages/84/cc/7678f901cbf5bca8ccace568ae85ee7baddcd93d78754ac43a3bb5e5a7ac/pyobjc_framework_intentsui-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a877555e313d74ac3b10f7b4e738647eea9f744c00a227d1238935ac3f9d7968", size = 8961, upload-time = "2025-11-14T09:52:05.595Z" }, ] [[package]] name = "pyobjc-framework-iobluetooth" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7f/a2/639dd9503842ec12ecd2712b58baf47df96ca170651828a7dc8e7a721a9e/pyobjc_framework_iobluetooth-12.0.tar.gz", hash = "sha256:44eb58bab83172f0bba41928a5831a8aa852151485dc87252229f0542cecd7c8", size = 155642, upload-time = "2025-10-21T08:34:22.012Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/aa/ca3944bbdfead4201b4ae6b51510942c5a7d8e5e2dc3139a071c74061fdf/pyobjc_framework_iobluetooth-12.1.tar.gz", hash = "sha256:8a434118812f4c01dfc64339d41fe8229516864a59d2803e9094ee4cbe2b7edd", size = 155241, upload-time = "2025-11-14T10:16:28.896Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/68/086ee6f5a4a0b6c59d9b2e2775252c6ba18853ecfc726e6f3095ddf285b8/pyobjc_framework_iobluetooth-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:921ae54acf5d823678686eb4945f6875f98146ebcdc4cb6a115468a73bb7864d", size = 40419, upload-time = "2025-10-21T08:10:04.061Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/ad6b36f574c3d52b5e935b1d57ab0f14f4e4cd328cc922d2b6ba6428c12d/pyobjc_framework_iobluetooth-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:77959f2ecf379aa41eb0848fdb25da7c322f9f4a82429965c87c4bc147137953", size = 40415, upload-time = "2025-11-14T09:52:22.069Z" }, ] [[package]] name = "pyobjc-framework-iobluetoothui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-iobluetooth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/74/95/22588965d90ce13e9ac65d46b9c97379a9400336052663c3b8066f5b2c70/pyobjc_framework_iobluetoothui-12.0.tar.gz", hash = "sha256:a768e16ce112b3a01fbc324e9cb5976a1d908069df8aa0d2b77f0f6f56cd4ad6", size = 16536, upload-time = "2025-10-21T08:34:24.041Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/39/31d9a4e8565a4b1ec0a9ad81480dc0879f3df28799eae3bc22d1dd53705d/pyobjc_framework_iobluetoothui-12.1.tar.gz", hash = "sha256:81f8158bdfb2966a574b6988eb346114d6a4c277300c8c0a978c272018184e6f", size = 16495, upload-time = "2025-11-14T10:16:31.212Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/af/b6df402c5a82da4f1a6d1b97cf251a6b5c687256e7007201f42caeaa00f1/pyobjc_framework_iobluetoothui-12.0-py2.py3-none-any.whl", hash = "sha256:2bfb0bf3589db9b4a06132503d2998490d5f2ad56e2259fb066c05f19b71754a", size = 4056, upload-time = "2025-10-21T08:10:25.203Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c9/69aeda0cdb5d25d30dc4596a1c5b464fc81b5c0c4e28efc54b7e11bde51c/pyobjc_framework_iobluetoothui-12.1-py2.py3-none-any.whl", hash = "sha256:a6d8ab98efa3029130577a57ee96b183c35c39b0f1c53a7534f8838260fab993", size = 4045, upload-time = "2025-11-14T09:52:42.201Z" }, ] [[package]] name = "pyobjc-framework-iosurface" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/8f/b4767fbf4ba4219d92d7c2ac2e48425342442f9ecea7adb351da6bc65da1/pyobjc_framework_iosurface-12.0.tar.gz", hash = "sha256:456a706e73e698494aec539e713341f6b1bd4c870c95a0e554fe0b8d32dfda06", size = 17739, upload-time = "2025-10-21T08:34:26.355Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/61/0f12ad67a72d434e1c84b229ec760b5be71f53671ee9018593961c8bfeb7/pyobjc_framework_iosurface-12.1.tar.gz", hash = "sha256:4b9d0c66431aa296f3ca7c4f84c00dc5fc961194830ad7682fdbbc358fa0db55", size = 17690, upload-time = "2025-11-14T10:16:33.282Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/9c/e65b489d448ec26bf3567228788fb36931412719447c8e87002375de42b4/pyobjc_framework_iosurface-12.0-py2.py3-none-any.whl", hash = "sha256:734543a79f6bceb0ade88138f83657c23422c33f2b83f732d09581f54c486ae3", size = 4913, upload-time = "2025-10-21T08:10:26.678Z" }, + { url = "https://files.pythonhosted.org/packages/88/ad/793d98a7ed9b775dc8cce54144cdab0df1808a1960ee017e46189291a8f3/pyobjc_framework_iosurface-12.1-py2.py3-none-any.whl", hash = "sha256:e784e248397cfebef4655d2c0025766d3eaa4a70474e363d084fc5ce2a4f2a3f", size = 4902, upload-time = "2025-11-14T09:52:43.899Z" }, ] [[package]] name = "pyobjc-framework-ituneslibrary" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/94/d7f8ac73777323c01859136bf50ba6cfc674fc8c5eedb0aa45ad3fa6b4cd/pyobjc_framework_ituneslibrary-12.0.tar.gz", hash = "sha256:f859806281d7604e71ddbf2323daa853ccb83a3295f631cab106e93900383d57", size = 23745, upload-time = "2025-10-21T08:34:29.075Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/46/d9bcec88675bf4ee887b9707bd245e2a793e7cb916cf310f286741d54b1f/pyobjc_framework_ituneslibrary-12.1.tar.gz", hash = "sha256:7f3aa76c4d05f6fa6015056b88986cacbda107c3f29520dd35ef0936c7367a6e", size = 23730, upload-time = "2025-11-14T10:16:36.127Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/20/b5a88ab437898ba43be98634a3aa8418b8990c045821059fb199dbf6c550/pyobjc_framework_ituneslibrary-12.0-py2.py3-none-any.whl", hash = "sha256:7274a34ef8e3d51754c571af3a49d49a3c946abf30562e9f647f53626dbea5e2", size = 5220, upload-time = "2025-10-21T08:10:30.203Z" }, + { url = "https://files.pythonhosted.org/packages/de/92/b598694a1713ee46f45c4bfb1a0425082253cbd2b1caf9f8fd50f292b017/pyobjc_framework_ituneslibrary-12.1-py2.py3-none-any.whl", hash = "sha256:fb678d7c3ff14c81672e09c015e25880dac278aa819971f4d5f75d46465932ef", size = 5205, upload-time = "2025-11-14T09:52:45.733Z" }, ] [[package]] name = "pyobjc-framework-kernelmanagement" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/d8/54cdf0e439b71e11dd081dfbdc0c23fd9122a90deab2a819a9ef08b6abab/pyobjc_framework_kernelmanagement-12.0.tar.gz", hash = "sha256:f7fa54676777f525eda77c261a6f2120256855f28531fd18fd0081be869d003d", size = 11836, upload-time = "2025-10-21T08:34:30.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/7e/ecbac119866e8ac2cce700d7a48a4297946412ac7cbc243a7084a6582fb1/pyobjc_framework_kernelmanagement-12.1.tar.gz", hash = "sha256:488062893ac2074e0c8178667bf864a21f7909c11111de2f6a10d9bc579df59d", size = 11773, upload-time = "2025-11-14T10:16:38.216Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/26/57122ddbe123b20b02b3c0510fc80719507ac849e311479d47225c13f7c2/pyobjc_framework_kernelmanagement-12.0-py2.py3-none-any.whl", hash = "sha256:a7cc70a131dbd3eb8b0b22c5283baf9b6c52ecbf26a5c689c254984719b17049", size = 3712, upload-time = "2025-10-21T08:10:31.777Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/04325a20f39d88d6d712437e536961a9e6a4ec19f204f241de6ed54d1d84/pyobjc_framework_kernelmanagement-12.1-py2.py3-none-any.whl", hash = "sha256:926381bfbfbc985c3e6dfcb7004af21bb16ff66ecbc08912b925989a705944ff", size = 3704, upload-time = "2025-11-14T09:52:47.268Z" }, ] [[package]] name = "pyobjc-framework-latentsemanticmapping" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/67/40a1c7d581a258f8dc436e3768f137d9c3885346f6f8aabcd35d9a472147/pyobjc_framework_latentsemanticmapping-12.0.tar.gz", hash = "sha256:737f2ceb84c85ab5352ad361f674c66be7602a5d2d68fbcfbe28400cf04fb1fa", size = 15564, upload-time = "2025-10-21T08:34:33.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/3c/b621dac54ae8e77ac25ee75dd93e310e2d6e0faaf15b8da13513258d6657/pyobjc_framework_latentsemanticmapping-12.1.tar.gz", hash = "sha256:f0b1fa823313eefecbf1539b4ed4b32461534b7a35826c2cd9f6024411dc9284", size = 15526, upload-time = "2025-11-14T10:16:40.149Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/57/bc9764affff2e6b3cea4c3e8bf527fc70b2bba600f1f4d079a3ecfd2b090/pyobjc_framework_latentsemanticmapping-12.0-py2.py3-none-any.whl", hash = "sha256:de98fb922e209f16cbacdaf60c186893b384fda9077293dd74257ea118502780", size = 5483, upload-time = "2025-10-21T08:10:33.389Z" }, + { url = "https://files.pythonhosted.org/packages/29/8e/74a7eb29b545f294485cd3cf70557b4a35616555fe63021edbb3e0ea4c20/pyobjc_framework_latentsemanticmapping-12.1-py2.py3-none-any.whl", hash = "sha256:7d760213b42bc8b1bc1472e1873c0f78ee80f987225978837b1fecdceddbdbf4", size = 5471, upload-time = "2025-11-14T09:52:48.939Z" }, ] [[package]] name = "pyobjc-framework-launchservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-coreservices" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/a8/c93919c0e249f3453ea2e2732ea1b69e959ac50bf63d8bf87017a8def36c/pyobjc_framework_launchservices-12.0.tar.gz", hash = "sha256:8c162e7f021b8428a35989fb86bc6dfb251456ec18b6e7570a83b3c32a683438", size = 20500, upload-time = "2025-10-21T08:34:35.212Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/d0/24673625922b0ad21546be5cf49e5ec1afaa4553ae92f222adacdc915907/pyobjc_framework_launchservices-12.1.tar.gz", hash = "sha256:4d2d34c9bd6fb7f77566155b539a2c70283d1f0326e1695da234a93ef48352dc", size = 20470, upload-time = "2025-11-14T10:16:42.499Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/51/f249292cb459f25c3ea09cdee7b8faaeb9cd06d62a02e453f450c5015879/pyobjc_framework_launchservices-12.0-py2.py3-none-any.whl", hash = "sha256:e95d30f2f21eadfd815806f2183735d8c93ed960251ef9123850dcb1b62c9384", size = 3912, upload-time = "2025-10-21T08:10:35.19Z" }, + { url = "https://files.pythonhosted.org/packages/08/af/9a0aebaab4c15632dc8fcb3669c68fa541a3278d99541d9c5f966fbc0909/pyobjc_framework_launchservices-12.1-py2.py3-none-any.whl", hash = "sha256:e63e78fceeed4d4dc807f9dabd5cf90407e4f552fab6a0d75a8d0af63094ad3c", size = 3905, upload-time = "2025-11-14T09:52:50.71Z" }, ] [[package]] name = "pyobjc-framework-libdispatch" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/7e/251ea268ce5a341586c963de758c7ff6dea681c98a1fb6da87f6d0004bd3/pyobjc_framework_libdispatch-12.0.tar.gz", hash = "sha256:2ef31c02670c377d9e2875e74053087b1d96b240d2fc8721cc4c665c05394b3a", size = 38599, upload-time = "2025-10-21T08:34:38.878Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277, upload-time = "2025-11-14T10:16:46.235Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/c2/7aff056399d9743a8c66af1ef575cf1741ce4c67c13c02d6510f0bd6151e/pyobjc_framework_libdispatch-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea093cd250105726aff61df189daa893e6f7bd43f8865bb6e612deeec233d374", size = 20472, upload-time = "2025-10-21T08:10:41.466Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/c4aeab6ce7268373d4ceabbc5c406c4bbf557038649784384910932985f8/pyobjc_framework_libdispatch-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:954cc2d817b71383bd267cc5cd27d83536c5f879539122353ca59f1c945ac706", size = 20463, upload-time = "2025-11-14T09:52:55.703Z" }, ] [[package]] name = "pyobjc-framework-libxpc" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/d3/e03390b44ff0c7c4542f5626e808f80f794e93a34a883377339cc1a18b0b/pyobjc_framework_libxpc-12.0.tar.gz", hash = "sha256:bf29f76f743a2af6cc5e294b34d671155257ef3f9751f92b821ecae75a9e7e52", size = 35557, upload-time = "2025-10-21T08:34:42.058Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/e4/364db7dc26f235e3d7eaab2f92057f460b39800bffdec3128f113388ac9f/pyobjc_framework_libxpc-12.1.tar.gz", hash = "sha256:e46363a735f3ecc9a2f91637750623f90ee74f9938a4e7c833e01233174af44d", size = 35186, upload-time = "2025-11-14T10:16:49.503Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/74/8fbdea024ce3863bd598c96c3d614e331125ba17814fd84c3a3957712469/pyobjc_framework_libxpc-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:97285c0c8c61230e13b78e0e4a12adcaca25123c2210ea6f36372c17c70ccc5d", size = 19627, upload-time = "2025-10-21T08:10:57.143Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/701630d025407497b7af50a795ddb6202c184da7f12b46aa683dae3d3552/pyobjc_framework_libxpc-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8d7201db995e5dcd38775fd103641d8fb69b8577d8e6a405c5562e6c0bb72fd1", size = 19620, upload-time = "2025-11-14T09:53:12.529Z" }, ] [[package]] name = "pyobjc-framework-linkpresentation" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/35/63a070df5478caa26b5babe80002f4cca6fe2324061dd11a9b6c564c829b/pyobjc_framework_linkpresentation-12.0.tar.gz", hash = "sha256:e98d035cbe943720dbb28873b510916c168a27e80614cf34b65c619c372e8d98", size = 13373, upload-time = "2025-10-21T08:34:43.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/58/c0c5919d883485ccdb6dccd8ecfe50271d2f6e6ab7c9b624789235ccec5a/pyobjc_framework_linkpresentation-12.1.tar.gz", hash = "sha256:84df6779591bb93217aa8bd82c10e16643441678547d2d73ba895475a02ade94", size = 13330, upload-time = "2025-11-14T10:16:52.169Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/0a/43ef70f68840ebaff950052b23be84ef3f9620ca628a56501a287f8bfec7/pyobjc_framework_linkpresentation-12.0-py2.py3-none-any.whl", hash = "sha256:d895cada661657c3d43525372ab38294352cceba7a007ee8464af5ce822153c7", size = 3876, upload-time = "2025-10-21T08:11:10.904Z" }, + { url = "https://files.pythonhosted.org/packages/ad/51/226eb45f196f3bf93374713571aae6c8a4760389e1d9435c4a4cc3f38ea4/pyobjc_framework_linkpresentation-12.1-py2.py3-none-any.whl", hash = "sha256:853a84c7b525b77b114a7a8d798aef83f528ed3a6803bda12184fe5af4e79a47", size = 3865, upload-time = "2025-11-14T09:53:28.386Z" }, ] [[package]] name = "pyobjc-framework-localauthentication" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-security" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/20/6744b25940d9462e0410cadd6da2e25ea3c01e6067a1234d8092ae0a40fa/pyobjc_framework_localauthentication-12.0.tar.gz", hash = "sha256:6287b671d4e418419d8d5b2244616d72f346f6b8a8bc18d9a6bccb93a291091c", size = 30327, upload-time = "2025-10-21T08:34:46.643Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/0e/7e5d9a58bb3d5b79a75d925557ef68084171526191b1c0929a887a553d4f/pyobjc_framework_localauthentication-12.1.tar.gz", hash = "sha256:2284f587d8e1206166e4495b33f420c1de486c36c28c4921d09eec858a699d05", size = 29947, upload-time = "2025-11-14T10:16:54.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/44/d5df20bd83f83cf789278df5a3efc6054c72eddb42dd85c7d5ed3baf98dd/pyobjc_framework_localauthentication-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1bb42a6866972676b63afd53cc96be4e720a48929eebfa18fdd5c3ef763270a8", size = 10768, upload-time = "2025-10-21T08:11:15.316Z" }, + { url = "https://files.pythonhosted.org/packages/6e/cb/cf9d13943e13dc868a68844448a7714c16f4ee6ecac384d21aaa5ac43796/pyobjc_framework_localauthentication-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2d7e1b3f987dc387361517525c2c38550dc44dfb3ba42dec3a9fbf35015831a6", size = 10762, upload-time = "2025-11-14T09:53:32.035Z" }, ] [[package]] name = "pyobjc-framework-localauthenticationembeddedui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-localauthentication" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/b9/b0ebb005d1a96733463e811f60b0cc254bef3bb8792769e22621d1af80cb/pyobjc_framework_localauthenticationembeddedui-12.0.tar.gz", hash = "sha256:6f54afb2380a190c0a3fb54f26cd1492ccc0eb9ce040cd20c2702c305dd866da", size = 13643, upload-time = "2025-10-21T08:34:48.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/20/83ab4180e29b9a4a44d735c7f88909296c6adbe6250e8e00a156aff753e1/pyobjc_framework_localauthenticationembeddedui-12.1.tar.gz", hash = "sha256:a15ec44bf2769c872e86c6b550b6dd4f58d4eda40ad9ff00272a67d279d1d4e9", size = 13611, upload-time = "2025-11-14T10:16:57.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/80/cfa1df39d32329350c9eec7b84a4cb966fe62679c463277bcfb75e8a03e0/pyobjc_framework_localauthenticationembeddedui-12.0-py2.py3-none-any.whl", hash = "sha256:0e78a1b41a47ca28310b4bece72bd52ba744a7f3386b8558d1b57129161a44bc", size = 3998, upload-time = "2025-10-21T08:11:29.039Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/0d46639c7a26b6af928ab4c822cd28b733791e02ac28cc84c3014bcf7dc7/pyobjc_framework_localauthenticationembeddedui-12.1-py2.py3-none-any.whl", hash = "sha256:a7ce7b56346597b9f4768be61938cbc8fc5b1292137225b6c7f631b9cde97cd7", size = 3991, upload-time = "2025-11-14T09:53:42.958Z" }, ] [[package]] name = "pyobjc-framework-mailkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/f0/f702efc9fe2a0c0dbb44728e7fd1edd75dd022edc54d51f2cb0fa001aaf0/pyobjc_framework_mailkit-12.0.tar.gz", hash = "sha256:98c45662428cfd4f672c170e2cc6c820bc1d625739a11603e3c267bebd18c6d8", size = 21015, upload-time = "2025-10-21T08:34:50.99Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/98/3d9028620c1cd32ff4fb031155aba3b5511e980cdd114dd51383be9cb51b/pyobjc_framework_mailkit-12.1.tar.gz", hash = "sha256:d5574b7259baec17096410efcaacf5d45c7bb5f893d4c25cbb7072369799b652", size = 20996, upload-time = "2025-11-14T10:16:59.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/4a/d5a86176153459264339d4c440dbc827e6f262788218534ce15c50ce37ab/pyobjc_framework_mailkit-12.0-py2.py3-none-any.whl", hash = "sha256:ef1241515f486a91ef6d5c548043ceb0de54103e76232d6c14d3082c0e99fe2e", size = 4880, upload-time = "2025-10-21T08:11:30.909Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/3c968b736a3a8bd9d8e870b39b1c772a013eea1b81b89fc4efad9021a6cb/pyobjc_framework_mailkit-12.1-py2.py3-none-any.whl", hash = "sha256:536ac0c4ea3560364cd159a6512c3c18a744a12e4e0883c07df0f8a2ff21e3fe", size = 4871, upload-time = "2025-11-14T09:53:44.697Z" }, ] [[package]] name = "pyobjc-framework-mapkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -2085,27 +2114,27 @@ dependencies = [ { name = "pyobjc-framework-corelocation" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/6d/6392039d550044b60fe2f716991c2543674b62837eed61254f356380a6f2/pyobjc_framework_mapkit-12.0.tar.gz", hash = "sha256:15b6078243797aea2fbf0eee003c2868fae735ce278db0b25b9aade01cf9564a", size = 63945, upload-time = "2025-10-21T08:34:55.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/bb/2a668203c20e509a648c35e803d79d0c7f7816dacba74eb5ad8acb186790/pyobjc_framework_mapkit-12.1.tar.gz", hash = "sha256:dbc32dc48e821aaa9b4294402c240adbc1c6834e658a07677b7c19b7990533c5", size = 63520, upload-time = "2025-11-14T10:17:04.221Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/0f/69c419cb574e8c873adbc37ddc69da241a7e6f1bb53d88b03eeb399fbde5/pyobjc_framework_mapkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f764a0fa8fc082400a3ad3cf2e2ac5fddabab26e932c25cae914a9c3626e4208", size = 22500, upload-time = "2025-10-21T08:11:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8f/411067e5c5cd23b9fe4c5edfb02ed94417b94eefe56562d36e244edc70ff/pyobjc_framework_mapkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e8aa82d4aae81765c05dcd53fd362af615aa04159fc7a1df1d0eac9c252cb7d5", size = 22493, upload-time = "2025-11-14T09:53:50.112Z" }, ] [[package]] name = "pyobjc-framework-mediaaccessibility" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/34/8d90408cf4e864e4800fe0fc481389c11e09f43dbe63305a73b98591fa80/pyobjc_framework_mediaaccessibility-12.0.tar.gz", hash = "sha256:bc9f2ca30dea75b43e5aa6d15dfbd2ec357d4afad42eb34f95d0056180e75182", size = 16374, upload-time = "2025-10-21T08:34:57.895Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/10/dc1007e56944ed2e981e69e7b2fed2b2202c79b0d5b742b29b1081d1cbdd/pyobjc_framework_mediaaccessibility-12.1.tar.gz", hash = "sha256:cc4e3b1d45e84133d240318d53424eff55968f5c6873c2c53267598853445a3f", size = 16325, upload-time = "2025-11-14T10:17:07.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/36/74b3970406cf5f831476f978513fc6614e8f40c1eb26f73e3a763e978547/pyobjc_framework_mediaaccessibility-12.0-py2.py3-none-any.whl", hash = "sha256:391244c646abe6489bd5886e4a5d11e7a3da5443f9a7a74bbd48520c19252082", size = 4809, upload-time = "2025-10-21T08:11:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0c/7fb5462561f59d739192c6d02ba0fd36ad7841efac5a8398a85a030ef7fc/pyobjc_framework_mediaaccessibility-12.1-py2.py3-none-any.whl", hash = "sha256:2ff8845c97dd52b0e5cf53990291e6d77c8a73a7aac0e9235d62d9a4256916d1", size = 4800, upload-time = "2025-11-14T09:54:05.04Z" }, ] [[package]] name = "pyobjc-framework-mediaextension" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -2113,264 +2142,264 @@ dependencies = [ { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-coremedia" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/7b/8ecced95e3a4f5e8fc639202bbdebb1ffbe444341b63f42f732b718cad00/pyobjc_framework_mediaextension-12.0.tar.gz", hash = "sha256:af68dd3cc6a647990322e55f6b37b63da783ad400816c238a8bae6f2fea72a07", size = 39809, upload-time = "2025-10-21T08:35:01.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/aa/1e8015711df1cdb5e4a0aa0ed4721409d39971ae6e1e71915e3ab72423a3/pyobjc_framework_mediaextension-12.1.tar.gz", hash = "sha256:44409d63cc7d74e5724a68e3f9252cb62fd0fd3ccf0ca94c6a33e5c990149953", size = 39425, upload-time = "2025-11-14T10:17:11.486Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/44/01c205b2b9b98e040bef95aa0700259d18d611fc3f1e00be1a87318e8d99/pyobjc_framework_mediaextension-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:30f122f45bf0dc2d0d48de1869d1364e87b1d3ab3c66de302cd9c9a08203b00d", size = 38973, upload-time = "2025-10-21T08:11:58.122Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6f/60b63edf5d27acf450e4937b7193c1a2bd6195fee18e15df6a5734dedb71/pyobjc_framework_mediaextension-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9555f937f2508bd2b6264cba088e2c2e516b2f94a6c804aee40e33fd89c2fb78", size = 38957, upload-time = "2025-11-14T09:54:13.22Z" }, ] [[package]] name = "pyobjc-framework-medialibrary" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/27/731cc25ea86cce6d19f3db99b1bb14d350ec6842120f834d7cc6f0001bab/pyobjc_framework_medialibrary-12.0.tar.gz", hash = "sha256:783b4a01ba731e3b7a1d0c76db66bc2be7ef0d6482ad153a65da7c996f1329cc", size = 16068, upload-time = "2025-10-21T08:35:03.639Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/e9/848ebd02456f8fdb41b42298ec585bfed5899dbd30306ea5b0a7e4c4b341/pyobjc_framework_medialibrary-12.1.tar.gz", hash = "sha256:690dcca09b62511df18f58e8566cb33d9652aae09fe63a83f594bd018b5edfcd", size = 15995, upload-time = "2025-11-14T10:17:15.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/57/5abdc5ef3ddd8a97bbcc0e9a375078f375d10f7e30222e1bef5348507fd2/pyobjc_framework_medialibrary-12.0-py2.py3-none-any.whl", hash = "sha256:f2a69aa959bf878bf6ce98d256e45d5ed19926f0d81d9ecbabd51ffdd2b54d18", size = 4372, upload-time = "2025-10-21T08:12:16.955Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/eeaf8585a343fda5b8cf3b8f144c872d1057c845202098b9441a39b76cb0/pyobjc_framework_medialibrary-12.1-py2.py3-none-any.whl", hash = "sha256:1f03ad6802a5c6e19ee3208b065689d3ec79defe1052cb80e00f54e1eff5f2a0", size = 4361, upload-time = "2025-11-14T09:54:32.259Z" }, ] [[package]] name = "pyobjc-framework-mediaplayer" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-avfoundation" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/58/022b4daa464db3448be0481abefcf08634b2bc3f121641eb33dfb9e1ee03/pyobjc_framework_mediaplayer-12.0.tar.gz", hash = "sha256:800c5a7b6652be54cbeefb7c9b2de02a7eaec9b7fef7a91c354dfc16880664e7", size = 35440, upload-time = "2025-10-21T08:35:07.076Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/f0/851f6f47e11acbd62d5f5dcb8274afc969135e30018591f75bf3cbf6417f/pyobjc_framework_mediaplayer-12.1.tar.gz", hash = "sha256:5ef3f669bdf837d87cdb5a486ec34831542360d14bcba099c7c2e0383380794c", size = 35402, upload-time = "2025-11-14T10:17:18.97Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/2b/968ae22ef293c4b3f0373a28dd188156097b38494a7deadf30448b5666c7/pyobjc_framework_mediaplayer-12.0-py2.py3-none-any.whl", hash = "sha256:c754087dfdbd065bceb31cc224363e91b05305d530db4295cffbb0c3ae0613e4", size = 7131, upload-time = "2025-10-21T08:12:18.622Z" }, + { url = "https://files.pythonhosted.org/packages/58/c0/038ee3efd286c0fbc89c1e0cb688f4670ed0e5803aa36e739e79ffc91331/pyobjc_framework_mediaplayer-12.1-py2.py3-none-any.whl", hash = "sha256:85d9baec131807bfdf0f4c24d4b943e83cce806ab31c95c7e19c78e3fb7eefc8", size = 7120, upload-time = "2025-11-14T09:54:33.901Z" }, ] [[package]] name = "pyobjc-framework-mediatoolbox" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/18/c7db54e9feafab8a201d05a668d4ffc5272ea65413c1032e1171f5bb98ca/pyobjc_framework_mediatoolbox-12.0.tar.gz", hash = "sha256:fcf0bd774860120203763e141a72f11aeeb2624c6ccd9beab4c79e24d31fb493", size = 22746, upload-time = "2025-10-21T08:35:09.437Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/71/be5879380a161f98212a336b432256f307d1dcbaaaeb8ec988aea2ada2cd/pyobjc_framework_mediatoolbox-12.1.tar.gz", hash = "sha256:385b48746a5f08756ee87afc14037e552954c427ed5745d7ece31a21a7bad5ab", size = 22305, upload-time = "2025-11-14T10:17:22.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/6a/5a15a573fce30d1302db210759e4a3c89547c2078ff9dd9372a0339752ca/pyobjc_framework_mediatoolbox-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6f06e1c08b33eb5456fec6a7053053fddbe61e05abeac5d8465c295bd1fb19cd", size = 12667, upload-time = "2025-10-21T08:12:22.442Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/f20ebd3c590b2cc85cde3e608e49309bfccf9312e4aca7b7ea60908d36d7/pyobjc_framework_mediatoolbox-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74de0cb2d5aaa77e81f8b97eab0f39cd2fab5bf6fa7c6fb5546740cbfb1f8c1f", size = 12656, upload-time = "2025-11-14T09:54:39.215Z" }, ] [[package]] name = "pyobjc-framework-metal" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/fe/529b6061e9d2012330fd5089fb9db3b56061557ca97762c961688eca41ad/pyobjc_framework_metal-12.0.tar.gz", hash = "sha256:1a4c08118089239986a3c4f7b19722e18986626933f0960be027c682a70d8758", size = 182133, upload-time = "2025-10-21T08:35:21.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/06/a84f7eb8561d5631954b9458cfca04b690b80b5b85ce70642bc89335f52a/pyobjc_framework_metal-12.1.tar.gz", hash = "sha256:bb554877d5ee2bf3f340ad88e8fe1b85baab7b5ec4bd6ae0f4f7604147e3eae7", size = 181847, upload-time = "2025-11-14T10:17:34.157Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/b3/e364e20ca7929eb805d7bebb462cbb5d864ae2e874cf6488fdecaea165e5/pyobjc_framework_metal-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eed803a7a47586db394af967e3ad0b44dc25940525a08aa12fa790e2d5c8b092", size = 75931, upload-time = "2025-10-21T08:12:45.459Z" }, + { url = "https://files.pythonhosted.org/packages/1d/cf/edbb8b6dd084df3d235b74dbeb1fc5daf4d063ee79d13fa3bc1cb1779177/pyobjc_framework_metal-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:59e10f9b36d2e409f80f42b6175457a07b18a21ca57ff268f4bc519cd30db202", size = 75920, upload-time = "2025-11-14T09:55:01.048Z" }, ] [[package]] name = "pyobjc-framework-metalfx" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-metal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/22/dae4a062b18093668ea6e4abd7d0a4b122ee2e67f8482804a93baa7539f0/pyobjc_framework_metalfx-12.0.tar.gz", hash = "sha256:179d1f1f3efa42cbd788e40d424bf5f0335d72282c766d9f79868b262904579b", size = 29852, upload-time = "2025-10-21T08:35:24.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/09/ce5c74565677fde66de3b9d35389066b19e5d1bfef9d9a4ad80f0c858c0c/pyobjc_framework_metalfx-12.1.tar.gz", hash = "sha256:1551b686fb80083a97879ce0331bdb1d4c9b94557570b7ecc35ebf40ff65c90b", size = 29470, upload-time = "2025-11-14T10:17:37.16Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/fb/77f251307a6d92490a01a07815f1b25f32dd1bded15f1459035276088cc0/pyobjc_framework_metalfx-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:600e4b02b25d66e589bc5d3fbc91d55b0ac04cef582bac33a9f22435513dd49b", size = 15034, upload-time = "2025-10-21T08:13:19.456Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e5/5494639c927085bbba1a310e73662e0bda44b90cdff67fa03a4e1c24d4c4/pyobjc_framework_metalfx-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ec3f7ab036eae45e067fbf209676f98075892aa307d73bb9394304960746cd2", size = 15026, upload-time = "2025-11-14T09:55:35.239Z" }, ] [[package]] name = "pyobjc-framework-metalkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-metal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/75/e9/668136ba83197b2ff34c018710d55abebd8de0267a138f12df0dde17772d/pyobjc_framework_metalkit-12.0.tar.gz", hash = "sha256:e5c2c27fc5ecd7dd553524cb3ccce7cbd0fa62d39e58e532a06ce977069a7132", size = 25878, upload-time = "2025-10-21T08:35:27.65Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/15/5091147aae12d4011a788b93971c3376aaaf9bf32aa935a2c9a06a71e18b/pyobjc_framework_metalkit-12.1.tar.gz", hash = "sha256:14cc5c256f0e3471b412a5b3582cb2a0d36d3d57401a8aa09e433252d1c34824", size = 25473, upload-time = "2025-11-14T10:17:39.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/05/30/f9c05e635d58c87f8aaa7c87eeb6827b6caaf5809ef9e8da3ebd51de60a7/pyobjc_framework_metalkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:35d7cf3f487d49f961058d54e84f07aead6d73137b7dd922e13ea8868b65415d", size = 8746, upload-time = "2025-10-21T08:13:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/10/c5/f72cbc3a5e83211cbfa33b60611abcebbe893854d0f2b28ff6f444f97549/pyobjc_framework_metalkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:28636454f222d9b20cb61f6e8dc1ebd48237903feb4d0dbdf9d7904c542475e5", size = 8735, upload-time = "2025-11-14T09:55:50.053Z" }, ] [[package]] name = "pyobjc-framework-metalperformanceshaders" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-metal" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/5f/86c48d83cf90da2f626a3134a51c0531a739ad325d64f7cf3e92ddcab8bf/pyobjc_framework_metalperformanceshaders-12.0.tar.gz", hash = "sha256:a87af3d89122fd35de03157d787c207eebd17446e4532868b8d70f1723cc476f", size = 137694, upload-time = "2025-10-21T08:35:37.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/68/58da38e54aa0d8c19f0d3084d8c84e92d54cc8c9254041f07119d86aa073/pyobjc_framework_metalperformanceshaders-12.1.tar.gz", hash = "sha256:b198e755b95a1de1525e63c3b14327ae93ef1d88359e6be1ce554a3493755b50", size = 137301, upload-time = "2025-11-14T10:17:49.554Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6f/e5d994c0a162eb7e1fadb1e58faa02fffa61b6f68fdf50d3e414a80534bb/pyobjc_framework_metalperformanceshaders-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:90fbdceba581a047ffa97a20f873d2b298f4ee35052539628ece2397ccd4684b", size = 32991, upload-time = "2025-10-21T08:13:50.596Z" }, + { url = "https://files.pythonhosted.org/packages/00/0f/6dc06a08599a3bc211852a5e6dcb4ed65dfbf1066958feb367ba7702798a/pyobjc_framework_metalperformanceshaders-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0159a6f731dc0fd126481a26490683586864e9d47c678900049a8ffe0135f56", size = 32988, upload-time = "2025-11-14T09:56:05.323Z" }, ] [[package]] name = "pyobjc-framework-metalperformanceshadersgraph" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-metalperformanceshaders" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/89/e9/4a57eb83ecb167528e3ae3114ad1bf114c56216449da5c236ae41f8ad797/pyobjc_framework_metalperformanceshadersgraph-12.0.tar.gz", hash = "sha256:8323f119faa1d2a141e9ac895b7b796e016e891e70ef0af000863714af845a21", size = 43030, upload-time = "2025-10-21T08:35:41.292Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/56/7ad0cd085532f7bdea9a8d4e9a2dfde376d26dd21e5eabdf1a366040eff8/pyobjc_framework_metalperformanceshadersgraph-12.1.tar.gz", hash = "sha256:b8fd017b47698037d7b172d41bed7a4835f4c4f2a288235819d200005f89ee35", size = 42992, upload-time = "2025-11-14T10:17:53.502Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/21/b4e0f21f013c54e0675b57a5523ee1c13b1bea73b34455a2450a92e9cc0e/pyobjc_framework_metalperformanceshadersgraph-12.0-py2.py3-none-any.whl", hash = "sha256:3e8f978d733e911fff61b212a27553142596edd53b80a630b20a0db06f59a601", size = 6491, upload-time = "2025-10-21T08:14:07.994Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c9/5e7fd0d4bc9bdf7b442f36e020677c721ba9b4c1dc1fa3180085f22a4ef9/pyobjc_framework_metalperformanceshadersgraph-12.1-py2.py3-none-any.whl", hash = "sha256:85a1c7a6114ada05c7924b3235a1a98c45359410d148097488f15aee5ebb6ab9", size = 6481, upload-time = "2025-11-14T09:56:23.66Z" }, ] [[package]] name = "pyobjc-framework-metrickit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ea/30/89f4731851814be85d100fd329fa1aa808648c73d702c9835b2ad9d0628f/pyobjc_framework_metrickit-12.0.tar.gz", hash = "sha256:ddfc464625433ab842a0ff86ea8663226f0dee8c75af4ac8f7e7478fef4fdddd", size = 28046, upload-time = "2025-10-21T08:35:44.229Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/13/5576ddfbc0b174810a49171e2dbe610bdafd3b701011c6ecd9b3a461de8a/pyobjc_framework_metrickit-12.1.tar.gz", hash = "sha256:77841daf6b36ba0c19df88545fd910c0516acf279e6b7b4fa0a712a046eaa9f1", size = 27627, upload-time = "2025-11-14T10:17:56.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/d1/a69b591cc5ab64ae84f0d34a7ed9b49f7e078ab8fb73c834bc34d81f2b38/pyobjc_framework_metrickit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b53cb8350fea3bc98702d984f1563c4e384773303153a76ecf2109cc89a5a9b", size = 8112, upload-time = "2025-10-21T08:14:12.54Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b0/e57c60af3e9214e05309dca201abb82e10e8cf91952d90d572b641d62027/pyobjc_framework_metrickit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:da6650afd9523cf7a9cae177f4bbd1ad45cc122d97784785fa1482847485142c", size = 8102, upload-time = "2025-11-14T09:56:27.194Z" }, ] [[package]] name = "pyobjc-framework-mlcompute" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/054839433183983c923d91e383cff027a8d6dc2f106d485869584fa4c030/pyobjc_framework_mlcompute-12.0.tar.gz", hash = "sha256:64bdaf38c564c583dbb242677acd8b4e0d2e100ea651953f61fecbb5ba94a844", size = 40717, upload-time = "2025-10-21T08:35:48.066Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/69/15f8ce96c14383aa783c8e4bc1e6d936a489343bb197b8e71abb3ddc1cb8/pyobjc_framework_mlcompute-12.1.tar.gz", hash = "sha256:3281db120273dcc56e97becffd5cedf9c62042788289f7be6ea067a863164f1e", size = 40698, upload-time = "2025-11-14T10:17:59.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/5d/aa7eaa1a5a3d709f8df2955b2898048e666d54e25473e74854384ecf4c06/pyobjc_framework_mlcompute-12.0-py2.py3-none-any.whl", hash = "sha256:ba172ffd3b3544a3dccd305b91b538da10f80214c3d8ddd2a730a5caa75669c7", size = 6753, upload-time = "2025-10-21T08:14:23.019Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f7/4614b9ccd0151795e328b9ed881fbcbb13e577a8ec4ae3507edb1a462731/pyobjc_framework_mlcompute-12.1-py2.py3-none-any.whl", hash = "sha256:4f0fc19551d710a03dfc4c7129299897544ff8ea76db6c7539ecc2f9b2571bde", size = 6744, upload-time = "2025-11-14T09:56:36.973Z" }, ] [[package]] name = "pyobjc-framework-modelio" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/a1/e4497a07fdbe81ef48fd33af1123ba2613d72a59f9affa6aeb0b302dc85f/pyobjc_framework_modelio-12.0.tar.gz", hash = "sha256:15341997259521e132b2010c0bea5928143e47de6772a447d4d1c834db0f7f01", size = 66906, upload-time = "2025-10-21T08:35:53.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/11/32c358111b623b4a0af9e90470b198fffc068b45acac74e1ba711aee7199/pyobjc_framework_modelio-12.1.tar.gz", hash = "sha256:d041d7bca7c2a4526344d3e593347225b7a2e51a499b3aa548895ba516d1bdbb", size = 66482, upload-time = "2025-11-14T10:18:04.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/30/6b6c417fc491dea3370e8a74a3d9863f83dba59d1ae742b641fafeecb240/pyobjc_framework_modelio-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0792e2330a8362e5ebc1d42766abed2a22d735179a604432e0bb0d1ad7367dbe", size = 20187, upload-time = "2025-10-21T08:14:28.188Z" }, + { url = "https://files.pythonhosted.org/packages/35/c0/c67b806f3f2bb6264a4f7778a2aa82c7b0f50dfac40f6a60366ffc5afaf5/pyobjc_framework_modelio-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1c2c99d47a7e4956a75ce19bddbe2d8ada7d7ce9e2f56ff53fc2898367187749", size = 20180, upload-time = "2025-11-14T09:56:41.924Z" }, ] [[package]] name = "pyobjc-framework-multipeerconnectivity" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/af/e1379399637fc292eae354e15a1a55037c9c198494f30f65c8a6cb3ad771/pyobjc_framework_multipeerconnectivity-12.0.tar.gz", hash = "sha256:91796d7a2b88ea2cc44c03474e6730e9f647a018406c324943c224c1f3ea1fc5", size = 23213, upload-time = "2025-10-21T08:35:55.98Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/35/0d0bb6881004cb238cfd7bf74f4b2e42601a1accdf27b2189ec61cf3a2dc/pyobjc_framework_multipeerconnectivity-12.1.tar.gz", hash = "sha256:7123f734b7174cacbe92a51a62b4645cc9033f6b462ff945b504b62e1b9e6c1c", size = 22816, upload-time = "2025-11-14T10:18:07.363Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/84/4476ac81f33e897535fcb5975cfaf55c6e1bf7aa98a0d23f0882ab519869/pyobjc_framework_multipeerconnectivity-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dd2799edc92018080bf19acfe6e6d857365ce945003f7ff9afde55a28925ace5", size = 11993, upload-time = "2025-10-21T08:14:44.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/eb/e3e4ba158167696498f6491f91a8ac7e24f1ebbab5042cd34318e5d2035c/pyobjc_framework_multipeerconnectivity-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7372e505ed050286aeb83d7e158fda65ad379eae12e1526f32da0a260a8b7d06", size = 11981, upload-time = "2025-11-14T09:56:58.858Z" }, ] [[package]] name = "pyobjc-framework-naturallanguage" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/91/785780967e0cf8f78ac2d69f3b7624d9fd52ec746bd655fb738fec584b39/pyobjc_framework_naturallanguage-12.0.tar.gz", hash = "sha256:a5fc834d9fe81cc2e45dd3749de3df0edfc9ab41b1c31efa4fcf0d00a51c9dfb", size = 23561, upload-time = "2025-10-21T08:35:58.811Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/d1/c81c0cdbb198d498edc9bc5fbb17e79b796450c17bb7541adbf502f9ad65/pyobjc_framework_naturallanguage-12.1.tar.gz", hash = "sha256:cb27a1e1e5b2913d308c49fcd2fd04ab5ea87cb60cac4a576a91ebf6a50e52f6", size = 23524, upload-time = "2025-11-14T10:18:09.883Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/0c/bfe280f01e61a2ef43f6fc341a8f039ff1e7a20283f159fda05c24f5c1b2/pyobjc_framework_naturallanguage-12.0-py2.py3-none-any.whl", hash = "sha256:acfb624e438a14285aaaa2233b064d875fe3895a0fc0578f67dc15fdba85e33b", size = 5330, upload-time = "2025-10-21T08:14:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d8/715a11111f76c80769cb267a19ecf2a4ac76152a6410debb5a4790422256/pyobjc_framework_naturallanguage-12.1-py2.py3-none-any.whl", hash = "sha256:a02ef383ec88948ca28f03ab8995523726b3bc75c49f593b5c89c218bcbce7ce", size = 5320, upload-time = "2025-11-14T09:57:10.294Z" }, ] [[package]] name = "pyobjc-framework-netfs" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/fd/f7df2b99f900856b15ea9cd425577cff4b7e0399c01b48fc317036e8067c/pyobjc_framework_netfs-12.0.tar.gz", hash = "sha256:0bbd02e171ba634c44a357763d3204f743af60004fd0a2bd76fd2e6918602c52", size = 14859, upload-time = "2025-10-21T08:36:00.739Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/68/4bf0e5b8cc0780cf7acf0aec54def58c8bcf8d733db0bd38f5a264d1af06/pyobjc_framework_netfs-12.1.tar.gz", hash = "sha256:e8d0c25f41d7d9ced1aa2483238d0a80536df21f4b588640a72e1bdb87e75c1e", size = 14799, upload-time = "2025-11-14T10:18:11.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/bc/d17ecc6a17327d7a950af52b8a68c471d7b5689108d77b9c079ec2ccc884/pyobjc_framework_netfs-12.0-py2.py3-none-any.whl", hash = "sha256:a1251a56a4a0716ebb97569993c5406b3adaecd16c9042347e8bce14fa3a140f", size = 4169, upload-time = "2025-10-21T08:14:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6b/8c2f223879edd3e3f030d0a9c9ba812775519c6d0c257e3e7255785ca6e7/pyobjc_framework_netfs-12.1-py2.py3-none-any.whl", hash = "sha256:0021f8b141e693d3821524c170e9c645090eb320e80c2935ddb978a6e8b8da81", size = 4163, upload-time = "2025-11-14T09:57:11.845Z" }, ] [[package]] name = "pyobjc-framework-network" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/e0/a51caeb37e7e737392c53a45a21418fd14057b8abea7a427347fbd6a3d6b/pyobjc_framework_network-12.0.tar.gz", hash = "sha256:5524e449c22e3feda1938bf071e64cec149cea4f1459959f2e7de513a6c902ec", size = 57385, upload-time = "2025-10-21T08:36:05.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/13/a71270a1b0a9ec979e68b8ec84b0f960e908b17b51cb3cac246a74d52b6b/pyobjc_framework_network-12.1.tar.gz", hash = "sha256:dbf736ff84d1caa41224e86ff84d34b4e9eb6918ae4e373a44d3cb597648a16a", size = 56990, upload-time = "2025-11-14T10:18:16.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/c6/d83d5c4d7f4f63a6240ddec3dd52d6efe52f1b1edcd599f696845a3b6b66/pyobjc_framework_network-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:220be97a68eec81d4b2e9068c8936bf5ef7033916be034a0b93e5b932cf77a00", size = 19604, upload-time = "2025-10-21T08:15:02.103Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/4f9fc6b94be3e949b7579128cbb9171943e27d1d7841db12d66b76aeadc3/pyobjc_framework_network-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d1ad948b9b977f432bf05363381d7d85a7021246ebf9d50771b35bf8d4548d2b", size = 19593, upload-time = "2025-11-14T09:57:17.027Z" }, ] [[package]] name = "pyobjc-framework-networkextension" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/ab/27769fdb0af13c8ba781b052fa7e1b5c77944665bab3a85a39fbf9f08f50/pyobjc_framework_networkextension-12.0.tar.gz", hash = "sha256:fff9e747d2d5da8352649028abaabc610bc3fa2779573e70df216aff7c00cb44", size = 63197, upload-time = "2025-10-21T08:36:10.071Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/3e/ac51dbb2efa16903e6af01f3c1f5a854c558661a7a5375c3e8767ac668e8/pyobjc_framework_networkextension-12.1.tar.gz", hash = "sha256:36abc339a7f214ab6a05cb2384a9df912f247163710741e118662bd049acfa2e", size = 62796, upload-time = "2025-11-14T10:18:21.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/6d/b939daf7fdbceaa6a41d5ed594270675937744feb191140c423f6ee6c366/pyobjc_framework_networkextension-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:23205ca928a5af2dd7e0f7d723c0b7dde0eaec6b5a15d298bc22d4ff8e5ae8b6", size = 14372, upload-time = "2025-10-21T08:15:19.336Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/aa34fc983f001cdb1afbbb4d08b42fd019fc9816caca0bf0b166db1688c1/pyobjc_framework_networkextension-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c3082c29f94ca3a05cd1f3219999ca3af9b6dece1302ccf789f347e612bb9303", size = 14368, upload-time = "2025-11-14T09:57:33.748Z" }, ] [[package]] name = "pyobjc-framework-notificationcenter" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/bd/76355e7ecdb558291c0699d825d962a1f53089645eee8e92dcc418aa13c8/pyobjc_framework_notificationcenter-12.0.tar.gz", hash = "sha256:ecec30ef99c440f7013eab2c147f413d9b87047eb3b4a6656ec58513f67fe61e", size = 21729, upload-time = "2025-10-21T08:36:12.827Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/12/ae0fe82fb1e02365c9fe9531c9de46322f7af09e3659882212c6bf24d75e/pyobjc_framework_notificationcenter-12.1.tar.gz", hash = "sha256:2d09f5ab9dc39770bae4fa0c7cfe961e6c440c8fc465191d403633dccc941094", size = 21282, upload-time = "2025-11-14T10:18:24.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/1d/756379b05a43ceeead1a20fbd355c420436dc6f90a61dcedcbffe31eff7d/pyobjc_framework_notificationcenter-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e13c69f1e1042a79d5d883df0b6e79fdd19c5bc149b2ffdcca36ef4a80a5fd5c", size = 9882, upload-time = "2025-10-21T08:15:33.566Z" }, + { url = "https://files.pythonhosted.org/packages/47/aa/03526fc0cc285c0f8cf31c74ce3a7a464011cc8fa82a35a1637d9878c788/pyobjc_framework_notificationcenter-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:84e254f2a56ff5372793dea938a2b2683dd0bc40c5107fede76f9c2c1f6641a2", size = 9871, upload-time = "2025-11-14T09:57:49.208Z" }, ] [[package]] name = "pyobjc-framework-opendirectory" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/3b/da8e6c62df0b721683940737a12f324342ee25e321fe8d26457bc394523e/pyobjc_framework_opendirectory-12.0.tar.gz", hash = "sha256:1fdcd865486b984dd19aa6e1f6ac200d43d1fb12ca34b56b44978ad19ed0b2b7", size = 61060, upload-time = "2025-10-21T08:36:17.564Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/11/bc2f71d3077b3bd078dccad5c0c5c57ec807fefe3d90c97b97dd0ed3d04b/pyobjc_framework_opendirectory-12.1.tar.gz", hash = "sha256:2c63ce5dd179828ef2d8f9e3961da3bfa971a57db07a6c34eedc296548a928bb", size = 61049, upload-time = "2025-11-14T10:18:29.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/44/e761c1bcf2516561d144668f85a0adcc60e2866475e6af56293b9a57c4ea/pyobjc_framework_opendirectory-12.0-py2.py3-none-any.whl", hash = "sha256:009de69034f254381786ee14cabacbc892d05204127caaeae8fe05d57172fffa", size = 11855, upload-time = "2025-10-21T08:15:44.141Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e7/3c2dece9c5b28af28a44d72a27b35ea5ffac31fed7cbd8d696ea75dc4a81/pyobjc_framework_opendirectory-12.1-py2.py3-none-any.whl", hash = "sha256:b5b5a5cf3cc2fb25147b16b79f046b90e3982bf3ded1b210a993d8cfdba737c4", size = 11845, upload-time = "2025-11-14T09:58:00.175Z" }, ] [[package]] name = "pyobjc-framework-osakit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/f8/f861aaf97c03525d530e269f63132a5dad37db2766eb2c08c5db74e0121e/pyobjc_framework_osakit-12.0.tar.gz", hash = "sha256:1662e40c5e28a254ff611310ef226194c6e22f2b731d2e877930e22a715f2144", size = 17119, upload-time = "2025-10-21T08:36:19.863Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/b9/bf52c555c75a83aa45782122432fa06066bb76469047f13d06fb31e585c4/pyobjc_framework_osakit-12.1.tar.gz", hash = "sha256:36ea6acf03483dc1e4344a0cce7250a9656f44277d12bc265fa86d4cbde01f23", size = 17102, upload-time = "2025-11-14T10:18:31.354Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/8a/2fabeb3f0e7be46ee64c31f7d17200fb8198139c82bca57db5344e11d1b9/pyobjc_framework_osakit-12.0-py2.py3-none-any.whl", hash = "sha256:807400db5845daaee55dbb6fbc63eadbfc120d12f4e62cb6135cf29929821f54", size = 4171, upload-time = "2025-10-21T08:15:45.638Z" }, + { url = "https://files.pythonhosted.org/packages/99/10/30a15d7b23e6fcfa63d41ca4c7356c39ff81300249de89c3ff28216a9790/pyobjc_framework_osakit-12.1-py2.py3-none-any.whl", hash = "sha256:c49165336856fd75113d2e264a98c6deb235f1bd033eae48f661d4d832d85e6b", size = 4162, upload-time = "2025-11-14T09:58:01.953Z" }, ] [[package]] name = "pyobjc-framework-oslog" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -2378,558 +2407,558 @@ dependencies = [ { name = "pyobjc-framework-coremedia" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/81/45878bbf7814e5cb6723f1cfd21e5a9f61ef2db5ce71cc32c66db89f31d2/pyobjc_framework_oslog-12.0.tar.gz", hash = "sha256:635548ab6cfd0201f6785d7c572bc7515eb0c2fe569e1b37f8742c164ea4b2cb", size = 21589, upload-time = "2025-10-21T08:36:22.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/42/805c9b4ac6ad25deb4215989d8fc41533d01e07ffd23f31b65620bade546/pyobjc_framework_oslog-12.1.tar.gz", hash = "sha256:d0ec6f4e3d1689d5e4341bc1130c6f24cb4ad619939f6c14d11a7e80c0ac4553", size = 21193, upload-time = "2025-11-14T10:18:33.645Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/83/d1d60ef0006bcf7f187074da7a6fc9e57aa7b8a470a440a537c52696b637/pyobjc_framework_oslog-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e2571519ccf58405896b9e5d1d64cfa7163f4da69a52460435eab67f185ad06", size = 7805, upload-time = "2025-10-21T08:15:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d5/8d37c2e733bd8a9a16546ceca07809d14401a059f8433cdc13579cd6a41a/pyobjc_framework_oslog-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8dd03386331fbb6b39df8941d99071da0bfeda7d10f6434d1daa1c69f0e7bb14", size = 7802, upload-time = "2025-11-14T09:58:05.619Z" }, ] [[package]] name = "pyobjc-framework-passkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/ca/4cdac3a3461f46261e70cbfb551eb51d6b0eac51eb918c6e685bc5c39566/pyobjc_framework_passkit-12.0.tar.gz", hash = "sha256:6a206195385a62472b71384799f85fb5c6316e819d9bdedf905efa150ec82313", size = 54214, upload-time = "2025-10-21T08:36:26.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/d4/2afb59fb0f99eb2f03888850887e536f1ef64b303fd756283679471a5189/pyobjc_framework_passkit-12.1.tar.gz", hash = "sha256:d8c27c352e86a3549bf696504e6b25af5f2134b173d9dd60d66c6d3da53bb078", size = 53835, upload-time = "2025-11-14T10:18:37.906Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/b4/db0a86a3cb1ea7ec03510d88030c6281314df7ce892c9e67118c921721a5/pyobjc_framework_passkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1e746b10867418fd0b6b8805f2e586ac17a66c94b6f3d7d637f27abbb9653ec7", size = 14091, upload-time = "2025-10-21T08:16:02.226Z" }, + { url = "https://files.pythonhosted.org/packages/25/e6/dabd6b99bdadc50aa0306495d8d0afe4b9b3475c2bafdad182721401a724/pyobjc_framework_passkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cb5c8f0fdc46db6b91c51ee1f41a2b81e9a482c96a0c91c096dcb78a012b740a", size = 14087, upload-time = "2025-11-14T09:58:18.991Z" }, ] [[package]] name = "pyobjc-framework-pencilkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/1d/c9ea9612680049a8b411acf817c77b18bae5180d8ad87753c172c9502b37/pyobjc_framework_pencilkit-12.0.tar.gz", hash = "sha256:efbead8c776bf9a24964586a70d937d54b087882b9b11a6e85478631e2a56f78", size = 17700, upload-time = "2025-10-21T08:36:28.537Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/43/859068016bcbe7d80597d5c579de0b84b0da62c5c55cdf9cc940e9f9c0f8/pyobjc_framework_pencilkit-12.1.tar.gz", hash = "sha256:d404982d1f7a474369f3e7fea3fbd6290326143fa4138d64b6753005a6263dc4", size = 17664, upload-time = "2025-11-14T10:18:40.045Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/d4/03f54c700d0278f6696cd9b3e5f65ab99aba3e5d026367b980d8ae566489/pyobjc_framework_pencilkit-12.0-py2.py3-none-any.whl", hash = "sha256:94794222210081205aa49f16f6c19be50c6ca73b598cbd8d8a1849bb1bf88075", size = 4218, upload-time = "2025-10-21T08:16:13.969Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/daf47dcfced8f7326218dced5c68ed2f3b522ec113329218ce1305809535/pyobjc_framework_pencilkit-12.1-py2.py3-none-any.whl", hash = "sha256:33b88e5ed15724a12fd8bf27a68614b654ff739d227e81161298bc0d03acca4f", size = 4206, upload-time = "2025-11-14T09:58:30.814Z" }, ] [[package]] name = "pyobjc-framework-phase" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-avfoundation" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/a2/7de65c8a8c9eaead9f3435ef433c4cc36b6480fcaeb92799a331ffa9bcd9/pyobjc_framework_phase-12.0.tar.gz", hash = "sha256:f1c004cc26a136a6dd6a36097865f37d725bd4ba03c59c7d23859af2ce855ac7", size = 32756, upload-time = "2025-10-21T08:36:31.821Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/51/3b25eaf7ca85f38ceef892fdf066b7faa0fec716f35ea928c6ffec6ae311/pyobjc_framework_phase-12.1.tar.gz", hash = "sha256:3a69005c572f6fd777276a835115eb8359a33673d4a87e754209f99583534475", size = 32730, upload-time = "2025-11-14T10:18:43.102Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/a6/5845a8710f2087199b512e47129f07f6c6a80d6eb3aa195f2c6a50bfe23a/pyobjc_framework_phase-12.0-py2.py3-none-any.whl", hash = "sha256:a520e94ac9163bd4c586bfefdb8a129a15c5fbda59d728c4135835e3ce5c6031", size = 6913, upload-time = "2025-10-21T08:16:15.556Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9f/1ae45db731e8d6dd3e0b408c3accd0cf3236849e671f95c7c8cf95687240/pyobjc_framework_phase-12.1-py2.py3-none-any.whl", hash = "sha256:99a1c1efc6644f5312cce3693117d4e4482538f65ad08fe59e41e2579b67ab17", size = 6902, upload-time = "2025-11-14T09:58:32.436Z" }, ] [[package]] name = "pyobjc-framework-photos" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/03/b6/db478ff16bf203a956a704de266c2f09e1a97cdbf386679724009d02dfce/pyobjc_framework_photos-12.0.tar.gz", hash = "sha256:3d910e0665e3b9ff9a72e43b82f2547cb33d4631e3b355e5d4cc3bae8089794b", size = 46460, upload-time = "2025-10-21T08:36:35.646Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/53/f8a3dc7f711034d2283e289cd966fb7486028ea132a24260290ff32d3525/pyobjc_framework_photos-12.1.tar.gz", hash = "sha256:adb68aaa29e186832d3c36a0b60b0592a834e24c5263e9d78c956b2b77dce563", size = 47034, upload-time = "2025-11-14T10:18:47.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/52/4cf272abba9dea78eaf3db8f03436520812c8486d7e65fecc093203f45f2/pyobjc_framework_photos-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:840fa12246293bfe2ef2412b2646bb988b91dbdb4b3748b457fd44f4b2a1e280", size = 12238, upload-time = "2025-10-21T08:16:19.291Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e0/8824f7cb167934a8aa1c088b7e6f1b5a9342b14694e76eda95fc736282b2/pyobjc_framework_photos-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f28db92602daac9d760067449fc9bf940594536e65ad542aec47d52b56f51959", size = 12319, upload-time = "2025-11-14T09:58:36.324Z" }, ] [[package]] name = "pyobjc-framework-photosui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d2/73/7a9adf5eda2a5de6e40527531beb9a84fc2ca897a103528317c5f14423a0/pyobjc_framework_photosui-12.0.tar.gz", hash = "sha256:59bc6a169129b8a63fc5e175923900df4957c469081686299e2ba384291972fc", size = 30235, upload-time = "2025-10-21T08:36:38.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/a5/14c538828ed1a420e047388aedc4a2d7d9292030d81bf6b1ced2ec27b6e9/pyobjc_framework_photosui-12.1.tar.gz", hash = "sha256:9141234bb9d17687f1e8b66303158eccdd45132341fbe5e892174910035f029a", size = 29886, upload-time = "2025-11-14T10:18:50.238Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/b6/abebb883165e8bc64bc3664fadca366c3aea2a88cf1b054192719eee1ca1/pyobjc_framework_photosui-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e56f6834cbe6a0c470dc1c9b4300253c77c2694728322e0031c425a8195f34c9", size = 11694, upload-time = "2025-10-21T08:16:33.57Z" }, + { url = "https://files.pythonhosted.org/packages/64/6c/d678767bbeafa932b91c88bc8bb3a586a1b404b5564b0dc791702eb376c3/pyobjc_framework_photosui-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:02ca941187b2a2dcbbd4964d7b2a05de869653ed8484dc059a51cc70f520cd07", size = 11688, upload-time = "2025-11-14T09:58:51.84Z" }, ] [[package]] name = "pyobjc-framework-preferencepanes" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/de/efe94e0c44a893893b8bac388a4a31d141f1fafa6085999cb09fd9dd1326/pyobjc_framework_preferencepanes-12.0.tar.gz", hash = "sha256:4c5a8df26846cada6c2cc7c1739d6b9334863a85cba509c3a62d92f13c18b112", size = 24630, upload-time = "2025-10-21T08:36:41.035Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/bc/e87df041d4f7f6b7721bf7996fa02aa0255939fb0fac0ecb294229765f92/pyobjc_framework_preferencepanes-12.1.tar.gz", hash = "sha256:b2a02f9049f136bdeca7642b3307637b190850e5853b74b5c372bc7d88ef9744", size = 24543, upload-time = "2025-11-14T10:18:53.259Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/67/9ead9b61d31707d2c3ebcce7bbb019f2c469c1e069063d0dcaf76aa33a5b/pyobjc_framework_preferencepanes-12.0-py2.py3-none-any.whl", hash = "sha256:b9be4e2a69ad9809758b648b683438c3142f9803db6fab46a13e83ff31eff400", size = 4811, upload-time = "2025-10-21T08:16:45.044Z" }, + { url = "https://files.pythonhosted.org/packages/36/7b/8ceec1ab0446224d685e243e2770c5a5c92285bcab0b9324dbe7a893ae5a/pyobjc_framework_preferencepanes-12.1-py2.py3-none-any.whl", hash = "sha256:1b3af9db9e0cfed8db28c260b2cf9a22c15fda5f0ff4c26157b17f99a0e29bbf", size = 4797, upload-time = "2025-11-14T09:59:03.998Z" }, ] [[package]] name = "pyobjc-framework-pushkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/08/0407f3752efde2913268b31dc40003a0175088683353134b437476a3bd80/pyobjc_framework_pushkit-12.0.tar.gz", hash = "sha256:202f95172bf35427eb5284c0005d72ef8a9dc5aa61f369bee371e1f1f76a2403", size = 19840, upload-time = "2025-10-21T08:36:45.061Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/45/de756b62709add6d0615f86e48291ee2bee40223e7dde7bbe68a952593f0/pyobjc_framework_pushkit-12.1.tar.gz", hash = "sha256:829a2fc8f4780e75fc2a41217290ee0ff92d4ade43c42def4d7e5af436d8ae82", size = 19465, upload-time = "2025-11-14T10:18:57.727Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/54/0bcba819c1e0ed1ca215e493e6736a441b1f065e66180158cfcd03c7c7b8/pyobjc_framework_pushkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a93d7250c135d517c398158a8316bf357a74b8015331731ac31c72462d19fa89", size = 8170, upload-time = "2025-10-21T08:16:50.664Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b2/d92045e0d4399feda83ee56a9fd685b5c5c175f7ac8423e2cd9b3d52a9da/pyobjc_framework_pushkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:03f41be8b27d06302ea487a6b250aaf811917a0e7d648cd4043fac759d027210", size = 8158, upload-time = "2025-11-14T09:59:09.593Z" }, ] [[package]] name = "pyobjc-framework-quartz" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/0b/3c34fc9de790daff5ca49d1f36cb8dcc353ac10e4e29b4759e397a3831f4/pyobjc_framework_quartz-12.0.tar.gz", hash = "sha256:5bcb9e78d671447e04d89e2e3c39f3135157892243facc5f8468aa333e40d67f", size = 3159509, upload-time = "2025-10-21T08:40:01.918Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/ed/13207ed99bd672a681cad3435512ab4e3217dd0cdc991c16a074ef6e7e95/pyobjc_framework_quartz-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6098bdb5db5837ecf6cf57f775efa9e5ce7c31f6452e4c4393de2198f5a3b06b", size = 217787, upload-time = "2025-10-21T08:17:29.353Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, ] [[package]] name = "pyobjc-framework-quicklookthumbnailing" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/64/3861655637e4beee4746e3f85af3f61028091d43f8b91fdff702285052b7/pyobjc_framework_quicklookthumbnailing-12.0.tar.gz", hash = "sha256:6b5ab7f8f75809535258c5af1db134e9f3449b36c5a40228766197527291297f", size = 14805, upload-time = "2025-10-21T08:40:04.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/1a/b90539500e9a27c2049c388d85a824fc0704009b11e33b05009f52a6dc67/pyobjc_framework_quicklookthumbnailing-12.1.tar.gz", hash = "sha256:4f7e09e873e9bda236dce6e2f238cab571baeb75eca2e0bc0961d5fcd85f3c8f", size = 14790, upload-time = "2025-11-14T10:21:26.442Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/16/da70d0c7aa6df70080e966e160fb0a545daa52a692c41a58cc659b6cdfe1/pyobjc_framework_quicklookthumbnailing-12.0-py2.py3-none-any.whl", hash = "sha256:6ff4dadb49e82319aa9391dbe759dc5d9fe3b7d30d87c6fb6efad22681c9426c", size = 4242, upload-time = "2025-10-21T08:18:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/1e/22/7bd07b5b44bf8540514a9f24bc46da68812c1fd6c63bb2d3496e5ea44bf0/pyobjc_framework_quicklookthumbnailing-12.1-py2.py3-none-any.whl", hash = "sha256:5efe50b0318188b3a4147681788b47fce64709f6fe0e1b5d020e408ef40ab08e", size = 4234, upload-time = "2025-11-14T10:01:02.209Z" }, ] [[package]] name = "pyobjc-framework-replaykit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/a5/c2875fb3a18da6a63a574b9628b052c93cf32884edd77e951b67b5c79e5b/pyobjc_framework_replaykit-12.0.tar.gz", hash = "sha256:9b04f20b04e78e9a6e4d0e85bd5e706a02ed939e9012f468b16dfb6fcc3ab03f", size = 23686, upload-time = "2025-10-21T08:40:06.926Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/f8/b92af879734d91c1726227e7a03b9e68ab8d9d2bb1716d1a5c29254087f2/pyobjc_framework_replaykit-12.1.tar.gz", hash = "sha256:95801fd35c329d7302b2541f2754e6574bf36547ab869fbbf41e408dfa07268a", size = 23312, upload-time = "2025-11-14T10:21:29.18Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/87/87a01c5cc5d515ac6dbd7db44f5906f905995b89ec9c1c7998898ddf3b4d/pyobjc_framework_replaykit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4137d25ae154c9c8f5ebbf16a8290b4505aebf32cf219a588d4d34e3ad24873f", size = 10102, upload-time = "2025-10-21T08:18:52.277Z" }, + { url = "https://files.pythonhosted.org/packages/10/b1/fab264c6a82a78cd050a773c61dec397c5df7e7969eba3c57e17c8964ea3/pyobjc_framework_replaykit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3a2f9da6939d7695fa40de9c560c20948d31b0cc2f892fdd611fc566a6b83606", size = 10090, upload-time = "2025-11-14T10:01:06.321Z" }, ] [[package]] name = "pyobjc-framework-safariservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/90/ada857aca483a83dacada061746badb0d9eb705311df4c43139909eb8c64/pyobjc_framework_safariservices-12.0.tar.gz", hash = "sha256:3fa9624285723cb9df282479bee315f0548ee91e1a277d9bd767c273fa7648fd", size = 25499, upload-time = "2025-10-21T08:40:09.716Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/4b/8f896bafbdbfa180a5ba1e21a6f5dc63150c09cba69d85f68708e02866ae/pyobjc_framework_safariservices-12.1.tar.gz", hash = "sha256:6a56f71c1e692bca1f48fe7c40e4c5a41e148b4e3c6cfb185fd80a4d4a951897", size = 25165, upload-time = "2025-11-14T10:21:32.041Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/29/727f14374e39a737d3f520cbe873e95b41ea9905e58516b41c0a0084dde9/pyobjc_framework_safariservices-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:54d4ef4f7dad2e60a051f84a1bebff3bdc8efa302bbf2b3ee093ae8d8eb4778b", size = 7295, upload-time = "2025-10-21T08:19:04.898Z" }, + { url = "https://files.pythonhosted.org/packages/f1/bb/da1059bfad021c417e090058c0a155419b735b4891a7eedc03177b376012/pyobjc_framework_safariservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae709cf7a72ac7b95d2f131349f852d5d7a1729a8d760ea3308883f8269a4c37", size = 7281, upload-time = "2025-11-14T10:01:19.294Z" }, ] [[package]] name = "pyobjc-framework-safetykit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/ab/9038e5067650af29ffb491df5a02a3c45da0690e4a2efcf10640bde195a2/pyobjc_framework_safetykit-12.0.tar.gz", hash = "sha256:eec3d74db7a0cdc4265cd29def24b8f1af3fdace8e309640e68c58c935157296", size = 20450, upload-time = "2025-10-21T08:40:12.565Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/bf/ad6bf60ceb61614c9c9f5758190971e9b90c45b1c7a244e45db64138b6c2/pyobjc_framework_safetykit-12.1.tar.gz", hash = "sha256:0cd4850659fb9b5632fd8ad21f2de6863e8303ff0d51c5cc9c0034aac5db08d8", size = 20086, upload-time = "2025-11-14T10:21:34.212Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/74/4275190d09a06e006f985efa7145fa64038c78e1c1ac736b850364e983c1/pyobjc_framework_safetykit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fbebcda5d29f0ba20762678b295b83ba40d9f017596b06fffc7575760de2ef78", size = 8550, upload-time = "2025-10-21T08:19:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/94/68/77f17fba082de7c65176e0d74aacbce5c9c9066d6d6edcde5a537c8c140a/pyobjc_framework_safetykit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6c63bcd5d571bba149e28c49c8db06073e54e073b08589e94b850b39a43e52b0", size = 8539, upload-time = "2025-11-14T10:01:31.201Z" }, ] [[package]] name = "pyobjc-framework-scenekit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/33/6e/d67322896c3f0f4ae940d1a7a2ed49bdcad139d8f7ab2eeff066d2a4ca8e/pyobjc_framework_scenekit-12.0.tar.gz", hash = "sha256:3c725a9fa2f5788d6451291d1c71db9b68f1cbb1969facaa514cd6e73a11d7c6", size = 101580, upload-time = "2025-10-21T08:40:19.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/8c/1f4005cf0cb68f84dd98b93bbc0974ee7851bb33d976791c85e042dc2278/pyobjc_framework_scenekit-12.1.tar.gz", hash = "sha256:1bd5b866f31fd829f26feac52e807ed942254fd248115c7c742cfad41d949426", size = 101212, upload-time = "2025-11-14T10:21:41.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/fd/524df6d6ca6b7f6877fd60c0403e73505a06e62aec2fa38f9f1df3f8cd08/pyobjc_framework_scenekit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41277e2893a0cdd620addc5c48a396ff9f2e499728ee77c48678537e26f47b6b", size = 33540, upload-time = "2025-10-21T08:19:31.436Z" }, + { url = "https://files.pythonhosted.org/packages/a0/7f/eda261013dc41cc70f3157d1a750712dc29b64fc05be84232006b5cd57e5/pyobjc_framework_scenekit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:01bf1336a7a8bdc96fabde8f3506aa7a7d1905e20a5c46030a57daf0ce2cbd16", size = 33542, upload-time = "2025-11-14T10:01:47.613Z" }, ] [[package]] name = "pyobjc-framework-screencapturekit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-coremedia" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/e5/6e1a3a5588d28eb7a80a2bd2feb8a76e32662ce169b309068121e94b0ea9/pyobjc_framework_screencapturekit-12.0.tar.gz", hash = "sha256:278743764adfbfc046b831bceaae2f0b4a42ea3b0b40e4ee349f9efcb62374e5", size = 32967, upload-time = "2025-10-21T08:40:23.005Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/7f/73458db1361d2cb408f43821a1e3819318a0f81885f833d78d93bdc698e0/pyobjc_framework_screencapturekit-12.1.tar.gz", hash = "sha256:50992c6128b35ab45d9e336f0993ddd112f58b8c8c8f0892a9cb42d61bd1f4c9", size = 32573, upload-time = "2025-11-14T10:21:44.497Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/06/ce09c0a558596063b9d903b2bf1ca25ab598929fcb5dbd266a47c2d3e461/pyobjc_framework_screencapturekit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cfb2f59776f80ae856b43a0dd3dc23dd79ea414f06106b249ece6f2fe37789bd", size = 11487, upload-time = "2025-10-21T08:19:51.749Z" }, + { url = "https://files.pythonhosted.org/packages/79/92/fe66408f4bd74f6b6da75977d534a7091efe988301d13da4f009bf54ab71/pyobjc_framework_screencapturekit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae412d397eedf189e763defe3497fcb8dffa5e0b54f62390cb33bf9b1cfb864a", size = 11473, upload-time = "2025-11-14T10:02:09.177Z" }, ] [[package]] name = "pyobjc-framework-screensaver" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/56/8262f65fddc0e86f52f589d7ac927b7c2ee6fb9b83c5906126a7544707b5/pyobjc_framework_screensaver-12.0.tar.gz", hash = "sha256:d1f875a89c511046d08304d801aba960e9ceef62808de104bb878d948696d29b", size = 22614, upload-time = "2025-10-21T08:40:25.795Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/99/7cfbce880cea61253a44eed594dce66c2b2fbf29e37eaedcd40cffa949e9/pyobjc_framework_screensaver-12.1.tar.gz", hash = "sha256:c4ca111317c5a3883b7eace0a9e7dd72bc6ffaa2ca954bdec918c3ab7c65c96f", size = 22229, upload-time = "2025-11-14T10:21:47.299Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/db/ba6dc945e1d0ac1877888fe9d425db98d7f73c0f52beaa401d9b0a3ebc1a/pyobjc_framework_screensaver-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:724713c35f7ff2c1ed1f2ed6785e7872ff14de74a36538fbedfae5eb1ab1b761", size = 8496, upload-time = "2025-10-21T08:20:05.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8d/87ca0fa0a9eda3097a0f4f2eef1544bf1d984697939fbef7cda7495fddb9/pyobjc_framework_screensaver-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5bd10809005fbe0d68fe651f32a393ce059e90da38e74b6b3cd055ed5b23eaa9", size = 8480, upload-time = "2025-11-14T10:02:22.798Z" }, ] [[package]] name = "pyobjc-framework-screentime" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/0a/369431b09cd9cfff0c6be01e256244d446ae8d37d95bcd8b79191078d5c3/pyobjc_framework_screentime-12.0.tar.gz", hash = "sha256:cf414fcb988b4ca408c82e1924f8ad9b52f3ff6d509a9dec5eb84983e1cd45bb", size = 13444, upload-time = "2025-10-21T08:40:27.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/11/ba18f905321895715dac3cae2071c2789745ae13605b283b8114b41e0459/pyobjc_framework_screentime-12.1.tar.gz", hash = "sha256:583de46b365543bbbcf27cd70eedd375d397441d64a2cf43c65286fd9c91af55", size = 13413, upload-time = "2025-11-14T10:21:49.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/fc/974228e9a93ad848f585ba74be4b0632ef18e652aa7459553a1490ffd276/pyobjc_framework_screentime-12.0-py2.py3-none-any.whl", hash = "sha256:c8046559698a53b7dfb7e7515fcfe5df850ffa0f6c093b5d825b5446af7e8604", size = 3975, upload-time = "2025-10-21T08:20:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/27/06/904174de6170e11b53673cc5844e5f13394eeeed486e0bcdf5288c1b0853/pyobjc_framework_screentime-12.1-py2.py3-none-any.whl", hash = "sha256:d34a068ec8ba2704987fcd05c37c9a9392de61d92933e6e71c8e4eaa4dfce029", size = 3963, upload-time = "2025-11-14T10:02:32.577Z" }, ] [[package]] name = "pyobjc-framework-scriptingbridge" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/ff/478ce8ba77b61b9b48bf2f881f0aec7c6059eb9166e29c6ee60223b09cb3/pyobjc_framework_scriptingbridge-12.0.tar.gz", hash = "sha256:062f03132fbf2f4e71bcf80d7e78c27d63588a1985d465ab1e7fa07f806590b5", size = 20710, upload-time = "2025-10-21T08:40:29.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/cb/adc0a09e8c4755c2281bd12803a87f36e0832a8fc853a2d663433dbb72ce/pyobjc_framework_scriptingbridge-12.1.tar.gz", hash = "sha256:0e90f866a7e6a8aeaf723d04c826657dd528c8c1b91e7a605f8bb947c74ad082", size = 20339, upload-time = "2025-11-14T10:21:51.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/10/02af88fd86af17661bdff02362fe4ba9b933a3dfd16344004298fb7ff6b6/pyobjc_framework_scriptingbridge-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f868ad91d15b6e016dfa636a8f16fd12a5ff99fbf7b84280400993b5b24cfe0f", size = 8343, upload-time = "2025-10-21T08:20:19.016Z" }, + { url = "https://files.pythonhosted.org/packages/42/de/0943ee8d7f1a7d8467df6e2ea017a6d5041caff2fb0283f37fea4c4ce370/pyobjc_framework_scriptingbridge-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e6e37e69760d6ac9d813decf135d107760d33e1cdf7335016522235607f6f31b", size = 8335, upload-time = "2025-11-14T10:02:36.654Z" }, ] [[package]] name = "pyobjc-framework-searchkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-coreservices" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/28/186a8525adb01657e2162ab8cd2ea3df17201bd1def22f460a6838301ca3/pyobjc_framework_searchkit-12.0.tar.gz", hash = "sha256:78c5fdd8f96da140883eabca82a3eb720a37e6e58c9a90d1c62dbe220a3fded5", size = 30949, upload-time = "2025-10-21T08:40:32.868Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/60/a38523198430e14fdef21ebe62a93c43aedd08f1f3a07ea3d96d9997db5d/pyobjc_framework_searchkit-12.1.tar.gz", hash = "sha256:ddd94131dabbbc2d7c3f17db3da87c1a712c431310eef16f07187771e7e85226", size = 30942, upload-time = "2025-11-14T10:21:55.483Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/00/e56077f1e21d55772064b645bd0b9359747967e9cb4599c48f79d3c77b99/pyobjc_framework_searchkit-12.0-py2.py3-none-any.whl", hash = "sha256:12dd4a566df2616dad316c95eb5b77fe7f98428a8cb707aee814328ce07bd6a8", size = 3742, upload-time = "2025-10-21T08:20:30.024Z" }, + { url = "https://files.pythonhosted.org/packages/72/46/4f9cd3011f47b43b21b2924ab3770303c3f0a4d16f05550d38c5fcb42e78/pyobjc_framework_searchkit-12.1-py2.py3-none-any.whl", hash = "sha256:844ce62b7296b19da8db7dedd539d07f7b3fb3bb8b029c261f7bcf0e01a97758", size = 3733, upload-time = "2025-11-14T10:02:47.026Z" }, ] [[package]] name = "pyobjc-framework-security" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/d6/ab109af82a65d52ab829010013b5a24b829c9155bc9608ebc80a43b8797c/pyobjc_framework_security-12.0.tar.gz", hash = "sha256:d64d069da79fbf1dadbc091717604843b9d5be96670f7b40bc9a08df12b4045b", size = 168360, upload-time = "2025-10-21T08:40:44.379Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/aa/796e09a3e3d5cee32ebeebb7dcf421b48ea86e28c387924608a05e3f668b/pyobjc_framework_security-12.1.tar.gz", hash = "sha256:7fecb982bd2f7c4354513faf90ba4c53c190b7e88167984c2d0da99741de6da9", size = 168044, upload-time = "2025-11-14T10:22:06.334Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/59/b7fecb01ae93980a93bfb027dddc793b58f39157b5e740972739404f6450/pyobjc_framework_security-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39b0b5886b1ed0bc38a21d98d3b1be948ab9e6ca5b9e52261f8aaae9214ca282", size = 41302, upload-time = "2025-10-21T08:20:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3d/8d3a39cd292d7c76ab76233498189bc7170fc80f573b415308464f68c7ee/pyobjc_framework_security-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b2d8819f0fb7b619ec7627a0d8c1cac1a57c5143579ce8ac21548165680684b", size = 41287, upload-time = "2025-11-14T10:02:54.491Z" }, ] [[package]] name = "pyobjc-framework-securityfoundation" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-security" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/f8/b806f00731237ef45d7cf6fdb12233320696e23e6bd04b14932027a03c81/pyobjc_framework_securityfoundation-12.0.tar.gz", hash = "sha256:55890147e294c5eb92f2467111ae577d18f15710ff3bb9caecb961b8397c5708", size = 12728, upload-time = "2025-10-21T08:40:46.366Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/d5/c2b77e83c1585ba43e5f00c917273ba4bf7ed548c1b691f6766eb0418d52/pyobjc_framework_securityfoundation-12.1.tar.gz", hash = "sha256:1f39f4b3db6e3bd3a420aaf4923228b88e48c90692cf3612b0f6f1573302a75d", size = 12669, upload-time = "2025-11-14T10:22:09.256Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d0/ececa41a50918594b8ee3f28af4174fb47740950e758585bc70c787f49b1/pyobjc_framework_securityfoundation-12.0-py2.py3-none-any.whl", hash = "sha256:01933f6f5424e11e19e833803b65873458d3a32de390f8c6bfa849e258f0c018", size = 3803, upload-time = "2025-10-21T08:20:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/349fb71a413b37b1b41e712c7ca180df82144478f8a9a59497d66d0f2ea2/pyobjc_framework_securityfoundation-12.1-py2.py3-none-any.whl", hash = "sha256:579cf23e63434226f78ffe0afb8426e971009588e4ad812c478d47dfd558201c", size = 3792, upload-time = "2025-11-14T10:03:14.459Z" }, ] [[package]] name = "pyobjc-framework-securityinterface" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-security" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/3b/0d263da7f2fa340e917b5a003d7dc34f930a60b4d489bdb29974890860c6/pyobjc_framework_securityinterface-12.0.tar.gz", hash = "sha256:6a17854bb37737b14684b379f2e3a7a71e4f2e5836aa3cdff7e9c179fc65369c", size = 25966, upload-time = "2025-10-21T08:40:48.931Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/64/bf5b5d82655112a2314422ee649f1e1e73d4381afa87e1651ce7e8444694/pyobjc_framework_securityinterface-12.1.tar.gz", hash = "sha256:deef11ad03be8d9ff77db6e7ac40f6b641ee2d72eaafcf91040537942472e88b", size = 25552, upload-time = "2025-11-14T10:22:12.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/9f/32b7a098b68ebda130ea3f2cbf5505fe8b52b9a3951b4731a5c537479429/pyobjc_framework_securityinterface-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41e3dacb1616490fca4c20ab7375386554bb4fc8836fa1f691fdfd062bfa4f4b", size = 10728, upload-time = "2025-10-21T08:21:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/a01fd56765792d1614eb5e8dc0a7d5467564be6a2056b417c9ec7efc648f/pyobjc_framework_securityinterface-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed599be750122376392e95c2407d57bd94644e8320ddef1d67660e16e96b0d06", size = 10719, upload-time = "2025-11-14T10:03:18.353Z" }, ] [[package]] name = "pyobjc-framework-securityui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-security" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b9/40ee5e3added96c9b2039e5016b7a994783c09580ac89eb5f077b9ed8810/pyobjc_framework_securityui-12.0.tar.gz", hash = "sha256:cbb5cfdb5f196ecb5b1c7369fa6af6e8a3c285013c8949b855b39bea4c09382e", size = 12206, upload-time = "2025-10-21T08:40:50.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/3f/d870305f5dec58cd02966ca06ac29b69fb045d8b46dfb64e2da31f295345/pyobjc_framework_securityui-12.1.tar.gz", hash = "sha256:f1435fed85edc57533c334a4efc8032170424b759da184cb7a7a950ceea0e0b6", size = 12184, upload-time = "2025-11-14T10:22:14.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/82/53bacd8fc7344bbce297f317f9a46ea0f4c75f9cdd3c72bc6b0b762b440e/pyobjc_framework_securityui-12.0-py2.py3-none-any.whl", hash = "sha256:9c7511241d19b416b79b1291eb57896ffc317528e6c342982722a32901a177a5", size = 3606, upload-time = "2025-10-21T08:21:11.839Z" }, + { url = "https://files.pythonhosted.org/packages/36/7f/eff9ffdd34511cc95a60e5bd62f1cfbcbcec1a5012ef1168161506628c87/pyobjc_framework_securityui-12.1-py2.py3-none-any.whl", hash = "sha256:3e988b83c9a2bb0393207eaa030fc023a8708a975ac5b8ea0508cdafc2b60705", size = 3594, upload-time = "2025-11-14T10:03:29.628Z" }, ] [[package]] name = "pyobjc-framework-sensitivecontentanalysis" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/fa/1a597c43747efb764f8d069b4d8db0458cdf14086ce9bd32fa41139484e1/pyobjc_framework_sensitivecontentanalysis-12.0.tar.gz", hash = "sha256:2e56f19af4506a0b222b223f70ab59725fc59b24d40267c1e03dcd3113f865ea", size = 13786, upload-time = "2025-10-21T08:40:52.907Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ce/17bf31753e14cb4d64fffaaba2377453c4977c2c5d3cf2ff0a3db30026c7/pyobjc_framework_sensitivecontentanalysis-12.1.tar.gz", hash = "sha256:2c615ac10e93eb547b32b214cd45092056bee0e79696426fd09978dc3e670f25", size = 13745, upload-time = "2025-11-14T10:22:16.447Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/0b/3be629ba18bec304236dba34e7bc592faa6a8486dd1188bd3994102ea2ec/pyobjc_framework_sensitivecontentanalysis-12.0-py2.py3-none-any.whl", hash = "sha256:fca905676790e76a2697c93fb798479aee3be5a57144ac681fa0e5cdc33e7d3a", size = 4240, upload-time = "2025-10-21T08:21:13.355Z" }, + { url = "https://files.pythonhosted.org/packages/95/23/c99568a0d4e38bd8337d52e4ae25a0b0bd540577f2e06f3430c951d73209/pyobjc_framework_sensitivecontentanalysis-12.1-py2.py3-none-any.whl", hash = "sha256:faf19d32d4599ac2b18fb1ccdc3e33b2b242bdf34c02e69978bd62d3643ad068", size = 4230, upload-time = "2025-11-14T10:03:31.26Z" }, ] [[package]] name = "pyobjc-framework-servicemanagement" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4a/76/8980c4451f27b646bf2b6b9895f155c780e040cfdddc66a3aca0125b93bf/pyobjc_framework_servicemanagement-12.0.tar.gz", hash = "sha256:768e0a288f38a4dcc65bbfc144fbccfc10fc29df72102b1a00923d78385d1c15", size = 14624, upload-time = "2025-10-21T08:40:55.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/d0/b26c83ae96ab55013df5fedf89337d4d62311b56ce3f520fc7597d223d82/pyobjc_framework_servicemanagement-12.1.tar.gz", hash = "sha256:08120981749a698033a1d7a6ab99dbbe412c5c0d40f2b4154014b52113511c1d", size = 14585, upload-time = "2025-11-14T10:22:18.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/c0/dc4c35cd42fc6e398d2b86f05a446007d3ae802cda187b8cf6834c3a248f/pyobjc_framework_servicemanagement-12.0-py2.py3-none-any.whl", hash = "sha256:57c22bb43aa6eb956aa5dee5976fe8602d45b72271e9ae9ed6f328645907fdac", size = 5366, upload-time = "2025-10-21T08:21:14.996Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5d/1009c32189f9cb26da0124b4a60640ed26dd8ad453810594f0cbfab0ff70/pyobjc_framework_servicemanagement-12.1-py2.py3-none-any.whl", hash = "sha256:9a2941f16eeb71e55e1cd94f50197f91520778c7f48ad896761f5e78725cc08f", size = 5357, upload-time = "2025-11-14T10:03:32.928Z" }, ] [[package]] name = "pyobjc-framework-sharedwithyou" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-sharedwithyoucore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/49/9fdb0d4e8c1f2d800975fb60d6975292767379e37250360072d9d84e9116/pyobjc_framework_sharedwithyou-12.0.tar.gz", hash = "sha256:e83152057aec724ede34be680bd98d5962b2e5d5443646fe41635fda9d5e996f", size = 25148, upload-time = "2025-10-21T08:40:57.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/8b/8ab209a143c11575a857e2111acc5427fb4986b84708b21324cbcbf5591b/pyobjc_framework_sharedwithyou-12.1.tar.gz", hash = "sha256:167d84794a48f408ee51f885210c616fda1ec4bff3dd8617a4b5547f61b05caf", size = 24791, upload-time = "2025-11-14T10:22:21.248Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/f5/49794fdc63f17f58b9cc9f6d3f7a851c0397c9bb8a1472d0ff8a1e18c1cd/pyobjc_framework_sharedwithyou-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dd6073e3371d208d30617a94c1ae93e097c77f253a49daaa2511e0e408a8f73c", size = 8756, upload-time = "2025-10-21T08:21:18.308Z" }, + { url = "https://files.pythonhosted.org/packages/19/69/3ad9b344808c5619adc253b665f8677829dfb978888227e07233d120cfab/pyobjc_framework_sharedwithyou-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:359c03096a6988371ea89921806bb81483ea509c9aa7114f9cd20efd511b3576", size = 8739, upload-time = "2025-11-14T10:03:36.48Z" }, ] [[package]] name = "pyobjc-framework-sharedwithyoucore" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/da/6e2f57bcfd4a5425a97d98c952d92f55c2ba8e5b7b227b2c122af9ab68f4/pyobjc_framework_sharedwithyoucore-12.0.tar.gz", hash = "sha256:ea923c3336c895d3dd79fa405f6fc17db6abbaac85ed8d7ed4ce9887e508ce1a", size = 22791, upload-time = "2025-10-21T08:41:00.157Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/ef/84059c5774fd5435551ab7ab40b51271cfb9997b0d21f491c6b429fe57a8/pyobjc_framework_sharedwithyoucore-12.1.tar.gz", hash = "sha256:0813149eeb755d718b146ec9365eb4ca3262b6af9ff9ba7db2f7b6f4fd104518", size = 22350, upload-time = "2025-11-14T10:22:23.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/46/366371e82b7d6d5b5185442be27b251a18b2a49c81ba873d9831c2a4fa41/pyobjc_framework_sharedwithyoucore-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a886bc070964b2693bb6575c60ea8b70446995b6dea18db3293b183349d68846", size = 8522, upload-time = "2025-10-21T08:21:31.189Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a1/83e58eca8827a1a9975a9c5de7f8c0bdc73b5f53ee79768d1fdbec6747de/pyobjc_framework_sharedwithyoucore-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4f9f7fed0768ebbbc2d24248365da2cf5f014b8822b2a1fbbce5fa920f410f1", size = 8512, upload-time = "2025-11-14T10:03:49.176Z" }, ] [[package]] name = "pyobjc-framework-shazamkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/21/1743b7d7592117f9739f0c14041e90c5de28b05a8b0c936602719b624fd4/pyobjc_framework_shazamkit-12.0.tar.gz", hash = "sha256:4624fc90435eaabb19c0079505a942e92b6cdf516830340289d543816fceca91", size = 22935, upload-time = "2025-10-21T08:41:02.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/2c/8d82c5066cc376de68ad8c1454b7c722c7a62215e5c2f9dac5b33a6c3d42/pyobjc_framework_shazamkit-12.1.tar.gz", hash = "sha256:71db2addd016874639a224ed32b2000b858802b0370c595a283cce27f76883fe", size = 22518, upload-time = "2025-11-14T10:22:25.996Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/91/dc1d060770503d0a6bbafbc49d2dd5dd75d4fb7342b8ba8715dd4259e333/pyobjc_framework_shazamkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e5dfdfbdb598f59a29ed30419327bd9eb3ac9daa9eca7e3f5180e0034510fa8", size = 8562, upload-time = "2025-10-21T08:21:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/92/12/09d83a8ac51dc11a574449dea48ffa99b3a7c9baf74afeedb487394d110d/pyobjc_framework_shazamkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0c10ba22de524fbedf06270a71bb0a3dbd4a3853b7002ddf54394589c3be6939", size = 8555, upload-time = "2025-11-14T10:04:02.552Z" }, ] [[package]] name = "pyobjc-framework-social" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/a0/034973099006522f01a32f83cf29458bd89acbd4b5a7f782358c9d781bf9/pyobjc_framework_social-12.0.tar.gz", hash = "sha256:be7d4b827537de49dea96c7defcfd28263b4a4cd4f28c5abeb873a072456db5b", size = 13229, upload-time = "2025-10-21T08:41:04.277Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/21/afc6f37dfdd2cafcba0227e15240b5b0f1f4ad57621aeefda2985ac9560e/pyobjc_framework_social-12.1.tar.gz", hash = "sha256:1963db6939e92ae40dd9d68852e8f88111cbfd37a83a9fdbc9a0c08993ca7e60", size = 13184, upload-time = "2025-11-14T10:22:28.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/dc/4da2473821c80acbfa65783430faad8923a0281e257960e5abcc821265b2/pyobjc_framework_social-12.0-py2.py3-none-any.whl", hash = "sha256:0bf4b935014f70957d0dd6316ce47c944495201c30990738d9be11431fa0db00", size = 4469, upload-time = "2025-10-21T08:21:53.037Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fb/090867e332d49a1e492e4b8972ac6034d1c7d17cf39f546077f35be58c46/pyobjc_framework_social-12.1-py2.py3-none-any.whl", hash = "sha256:2f3b36ba5769503b1bc945f85fd7b255d42d7f6e417d78567507816502ff2b44", size = 4462, upload-time = "2025-11-14T10:04:14.578Z" }, ] [[package]] name = "pyobjc-framework-soundanalysis" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/eb/30927f7d3e93913fcb4472bd2fb46b90cf341a52065c4c3bad3ffac463ad/pyobjc_framework_soundanalysis-12.0.tar.gz", hash = "sha256:eb60a6b172ca2d71f8b5ae9b6169a3b542755af0f763fec0786403f90b1394c5", size = 14871, upload-time = "2025-10-21T08:41:06.236Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/d6/5039b61edc310083425f87ce2363304d3a87617e941c1d07968c63b5638d/pyobjc_framework_soundanalysis-12.1.tar.gz", hash = "sha256:e2deead8b9a1c4513dbdcf703b21650dcb234b60a32d08afcec4895582b040b1", size = 14804, upload-time = "2025-11-14T10:22:29.998Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/2a/80786fe9e85ddb3b44828336911bd4bab99a2674cf9dd7912295f6c319a3/pyobjc_framework_soundanalysis-12.0-py2.py3-none-any.whl", hash = "sha256:08fd2e988ca0ae84c8dbaf490d634e250d32e44f420de7e6c2ff72bac947aaaf", size = 4197, upload-time = "2025-10-21T08:21:54.618Z" }, + { url = "https://files.pythonhosted.org/packages/53/d3/8df5183d52d20d459225d3f5d24f55e01b8cd9fe587ed972e3f20dd18709/pyobjc_framework_soundanalysis-12.1-py2.py3-none-any.whl", hash = "sha256:8b2029ab48c1a9772f247f0aea995e8c3ff4706909002a9c1551722769343a52", size = 4188, upload-time = "2025-11-14T10:04:16.12Z" }, ] [[package]] name = "pyobjc-framework-speech" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/73/623e37a98f0279cf4e5b6c160bcf8b510bb67d4f9fdc3202b48c326bdc66/pyobjc_framework_speech-12.0.tar.gz", hash = "sha256:9e6a208205e3065055e3d98b553464086ddc60f165df7e9c93596a819b4ab9b4", size = 25615, upload-time = "2025-10-21T08:41:08.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/3d/194cf19fe7a56c2be5dfc28f42b3b597a62ebb1e1f52a7dd9c55b917ac6c/pyobjc_framework_speech-12.1.tar.gz", hash = "sha256:2a2a546ba6c52d5dd35ddcfee3fd9226a428043d1719597e8701851a6566afdd", size = 25218, upload-time = "2025-11-14T10:22:32.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/63/995dbdaafa2f15d1f8a0c267588ff2d3c724c2484a3f79f5819a475c7df5/pyobjc_framework_speech-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:32aa8a1c357e2519da3047873bff1cce385c8603c58b58e10ee88428440a44f2", size = 9258, upload-time = "2025-10-21T08:21:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/03/54/77e12e4c23a98fc49d874f9703c9f8fd0257d64bb0c6ae329b91fc7a99e3/pyobjc_framework_speech-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0301bfae5d0d09b6e69bd4dbabc5631209e291cc40bda223c69ed0c618f8f2dc", size = 9248, upload-time = "2025-11-14T10:04:19.73Z" }, ] [[package]] name = "pyobjc-framework-spritekit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/a0/aababd3124b2303379d76dfd058b2c37d1609e6397f932a183dbb68b2d31/pyobjc_framework_spritekit-12.0.tar.gz", hash = "sha256:d2d673437d5863f59d4ed4cd1145c30c02cf7737b889573252d8d81cbb48e1db", size = 64834, upload-time = "2025-10-21T08:41:13.859Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/78/d683ebe0afb49f46d2d21d38c870646e7cb3c2e83251f264e79d357b1b74/pyobjc_framework_spritekit-12.1.tar.gz", hash = "sha256:a851f4ef5aa65cc9e08008644a528e83cb31021a1c0f17ebfce4de343764d403", size = 64470, upload-time = "2025-11-14T10:22:37.569Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/e3/6aa92eaaa6e3ea9cad1a575229cfb3e47ec8089f24922be7e4f054af54c8/pyobjc_framework_spritekit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0ad45adcdf1d1051f9f3931f01dd2728953ae5d57d517de12336399633640fa", size = 17749, upload-time = "2025-10-21T08:22:12.372Z" }, + { url = "https://files.pythonhosted.org/packages/60/6a/e8e44fc690d898394093f3a1c5fe90110d1fbcc6e3f486764437c022b0f8/pyobjc_framework_spritekit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26fd12944684713ae1e3cdd229348609c1142e60802624161ca0c3540eec3ffa", size = 17736, upload-time = "2025-11-14T10:04:33.202Z" }, ] [[package]] name = "pyobjc-framework-storekit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/a0/c8d7df4eb7f771838d6075c010b11fdf9d99bff2a60261b03ed196b22b03/pyobjc_framework_storekit-12.0.tar.gz", hash = "sha256:b72cbf8d79fa2f542765a9ccd75b3fc83ed0b985985c626e09ea268246416a95", size = 35012, upload-time = "2025-10-21T08:41:17.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/87/8a66a145feb026819775d44975c71c1c64df4e5e9ea20338f01456a61208/pyobjc_framework_storekit-12.1.tar.gz", hash = "sha256:818452e67e937a10b5c8451758274faa44ad5d4329df0fa85735115fb0608da9", size = 34574, upload-time = "2025-11-14T10:22:40.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/5c/fefc599ba997fdd3551a3d4cffcd7344057a4bff2017085942bae074339b/pyobjc_framework_storekit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13c5e3466a2388c6043c6fd36f0602d5e34bbfd1f2bce4a66e06f252ac5158e0", size = 12819, upload-time = "2025-10-21T08:22:27.723Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/af2afc4d27bde026cfd3b725ee1b082b2838dcaa9880ab719226957bc7cd/pyobjc_framework_storekit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a29f45bcba9dee4cf73dae05ab0f94d06a32fb052e31414d0c23791c1ec7931c", size = 12810, upload-time = "2025-11-14T10:04:48.693Z" }, ] [[package]] name = "pyobjc-framework-symbols" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/49/7e206fa8b912bd929bbcae17627f370ac6f81c75c1d2ca3a006fb12f4697/pyobjc_framework_symbols-12.0.tar.gz", hash = "sha256:0707226ae8741163f3f450559c7d7c87a987ddb84ccb5fe22fb1f40554404cfa", size = 12843, upload-time = "2025-10-21T08:41:19.35Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/ce/a48819eb8524fa2dc11fb3dd40bb9c4dcad0596fe538f5004923396c2c6c/pyobjc_framework_symbols-12.1.tar.gz", hash = "sha256:7d8e999b8a59c97d38d1d343b6253b1b7d04bf50b665700957d89c8ac43b9110", size = 12782, upload-time = "2025-11-14T10:22:42.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/eb/bec85c6ca8b765ff135297ce91acee1a63fbed8a9a5ad130dfb46e2ee50e/pyobjc_framework_symbols-12.0-py2.py3-none-any.whl", hash = "sha256:e47998c35073906cc5c82ca1eff73957d9f2b673621bad044cfa46b0b08697a6", size = 3345, upload-time = "2025-10-21T08:22:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ea/6e9af9c750d68109ac54fbffb5463e33a7b54ffe8b9901a5b6b603b7884b/pyobjc_framework_symbols-12.1-py2.py3-none-any.whl", hash = "sha256:c72eecbc25f6bfcd39c733067276270057c5aca684be20fdc56def645f2b6446", size = 3331, upload-time = "2025-11-14T10:05:01.333Z" }, ] [[package]] name = "pyobjc-framework-syncservices" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-coredata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/41/c7a6c68a0ceb7309ee4e167396a1d806543d7863a0e2945a835fd463359c/pyobjc_framework_syncservices-12.0.tar.gz", hash = "sha256:7ba335196f09495fade38753958ce5dcabe25a1280821ac69a77a1fc526d228d", size = 31454, upload-time = "2025-10-21T08:41:22.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/91/6d03a988831ddb0fb001b13573560e9a5bcccde575b99350f98fe56a2dd4/pyobjc_framework_syncservices-12.1.tar.gz", hash = "sha256:6a213e93d9ce15128810987e4c5de8c73cfab1564ac8d273e6b437a49965e976", size = 31032, upload-time = "2025-11-14T10:22:45.902Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/ea/e821da8003286fe2cfa9bd5df3b79311d5e3a347db9fed8e8e1f4f8326c7/pyobjc_framework_syncservices-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:00895ca29cffb71351affe0fec2ee849c40411ed0a81116d82acfc064403d781", size = 13390, upload-time = "2025-10-21T08:22:42.854Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9b/25c117f8ffe15aa6cc447da7f5c179627ebafb2b5ec30dfb5e70fede2549/pyobjc_framework_syncservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e81a38c2eb7617cb0ecfc4406c1ae2a97c60e95af42e863b2b0f1f6facd9b0da", size = 13380, upload-time = "2025-11-14T10:05:05.814Z" }, ] [[package]] name = "pyobjc-framework-systemconfiguration" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/a5/6d02fec1b04a7b44acf993157fd24ffbd7762c4937f3a733be3ae3899378/pyobjc_framework_systemconfiguration-12.0.tar.gz", hash = "sha256:441738af5663127e0bce23771ddaac25c891c0b09c22254b10a1de0933ed2ca2", size = 59482, upload-time = "2025-10-21T08:41:26.973Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/7d/50848df8e1c6b5e13967dee9fb91d3391fe1f2399d2d0797d2fc5edb32ba/pyobjc_framework_systemconfiguration-12.1.tar.gz", hash = "sha256:90fe04aa059876a21626931c71eaff742a27c79798a46347fd053d7008ec496e", size = 59158, upload-time = "2025-11-14T10:22:53.056Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/7d/eded231a496a07697f63f7dc3b7eb052a9bcd326b267daaca1ee834dc745/pyobjc_framework_systemconfiguration-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2f0f0a21f74bd771482d7f8e941f9b7f4eec1b8cfb67d88fd043af956e4780d8", size = 21675, upload-time = "2025-10-21T08:22:58.156Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7b/9126a7af1b798998837027390a20b981e0298e51c4c55eed6435967145cb/pyobjc_framework_systemconfiguration-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:796390a80500cc7fde86adc71b11cdc41d09507dd69103d3443fbb60e94fb438", size = 21663, upload-time = "2025-11-14T10:05:21.259Z" }, ] [[package]] name = "pyobjc-framework-systemextensions" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4b/ad/cad5b63d52a11d7e41a378753d30798d47bca41ecd1b519e4c34b1ee1ba7/pyobjc_framework_systemextensions-12.0.tar.gz", hash = "sha256:1eec39afc1a138cc31162577622542e65f0941a001aa4cac0e458bddbad76ba9", size = 21110, upload-time = "2025-10-21T08:41:29.288Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/01/8a706cd3f7dfcb9a5017831f2e6f9e5538298e90052db3bb8163230cbc4f/pyobjc_framework_systemextensions-12.1.tar.gz", hash = "sha256:243e043e2daee4b5c46cd90af5fff46b34596aac25011bab8ba8a37099685eeb", size = 20701, upload-time = "2025-11-14T10:22:58.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/d0/7424f5475cd7490b7766bc0e5f1310e828c16b16abf84e77315dc565a258/pyobjc_framework_systemextensions-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09f43783346420b8f2f5f692edd847cbd4042ab8a5d639f2195d70e9f04d5db1", size = 9161, upload-time = "2025-10-21T08:23:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a1/f8df6d59e06bc4b5989a76724e8551935e5b99aff6a21d3592e5ced91f1c/pyobjc_framework_systemextensions-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2a4e82160e43c0b1aa17e6d4435e840a655737fbe534e00e37fc1961fbf3bebd", size = 9156, upload-time = "2025-11-14T10:05:39.744Z" }, ] [[package]] name = "pyobjc-framework-threadnetwork" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/27/7d365ed3228c819e7cb3bf1c00530ad332b16b1f366fa68201ef6802b0e1/pyobjc_framework_threadnetwork-12.0.tar.gz", hash = "sha256:5c4b14ea351f2208e05f3a6b85e46eba4f11ab009af1251ea6caabfb6588dc42", size = 12810, upload-time = "2025-10-21T08:41:31.361Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/7e/f1816c3461e4121186f2f7750c58af083d1826bbd73f72728da3edcf4915/pyobjc_framework_threadnetwork-12.1.tar.gz", hash = "sha256:e071eedb41bfc1b205111deb54783ec5a035ccd6929e6e0076336107fdd046ee", size = 12788, upload-time = "2025-11-14T10:23:00.329Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/5e/660f7043d0946d47353f311aa4204e0063ddf768846bac402381542badaa/pyobjc_framework_threadnetwork-12.0-py2.py3-none-any.whl", hash = "sha256:e3f030bd6d36f01480e2f0d0639ada0c21d0d74bcc15f8b6301ebe525180e2f9", size = 3780, upload-time = "2025-10-21T08:23:24.825Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b8/94b37dd353302c051a76f1a698cf55b5ad50ca061db7f0f332aa9e195766/pyobjc_framework_threadnetwork-12.1-py2.py3-none-any.whl", hash = "sha256:07d937748fc54199f5ec04d5a408e8691a870481c11b641785c2adc279dd8e4b", size = 3771, upload-time = "2025-11-14T10:05:49.899Z" }, ] [[package]] name = "pyobjc-framework-uniformtypeidentifiers" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/8d/45e8290134b06e73fb1cdce72aea71bddf7d8dee820165a549379d32837e/pyobjc_framework_uniformtypeidentifiers-12.0.tar.gz", hash = "sha256:f7fe17832de25098b9ad7718af536f6f4597985418d9869946cee104e2782b8a", size = 17064, upload-time = "2025-10-21T08:41:33.528Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/b8/dd9d2a94509a6c16d965a7b0155e78edf520056313a80f0cd352413f0d0b/pyobjc_framework_uniformtypeidentifiers-12.1.tar.gz", hash = "sha256:64510a6df78336579e9c39b873cfcd03371c4b4be2cec8af75a8a3d07dff607d", size = 17030, upload-time = "2025-11-14T10:23:02.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/04/2b000e6e55572854c20eea7e0f4ba94597a6c8fb22a1fca9f1d2952a1ab6/pyobjc_framework_uniformtypeidentifiers-12.0-py2.py3-none-any.whl", hash = "sha256:b2c406e34306ef55ceb9c8cb16a4a9e37e7fc2ed4c8e7948f05bf3d51dea2a91", size = 4913, upload-time = "2025-10-21T08:23:26.31Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5f/1f10f5275b06d213c9897850f1fca9c881c741c1f9190cea6db982b71824/pyobjc_framework_uniformtypeidentifiers-12.1-py2.py3-none-any.whl", hash = "sha256:ec5411e39152304d2a7e0e426c3058fa37a00860af64e164794e0bcffee813f2", size = 4901, upload-time = "2025-11-14T10:05:51.532Z" }, ] [[package]] name = "pyobjc-framework-usernotifications" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/fc/3e5d15bddc660fc987cbf72b7b476dbe13bedcf52e18c58606432457d41e/pyobjc_framework_usernotifications-12.0.tar.gz", hash = "sha256:93dea828a26a3a93f6259f21496bcdda5dc1625a48c2ba9ce4a58c8a57d3f84c", size = 30118, upload-time = "2025-10-21T08:41:36.393Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/cd/e0253072f221fa89a42fe53f1a2650cc9bf415eb94ae455235bd010ee12e/pyobjc_framework_usernotifications-12.1.tar.gz", hash = "sha256:019ccdf2d400f9a428769df7dba4ea97c02453372bc5f8b75ce7ae54dfe130f9", size = 29749, upload-time = "2025-11-14T10:23:05.364Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/ad/b59797c1ec7cfc09d77edd1850a5bd8a37df4dfb95bc42b0904dfcab94db/pyobjc_framework_usernotifications-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:80a795bea7077e324d0a8d2d210e82ddf2e6cbaaea0c4ad32119fec470c79c24", size = 9640, upload-time = "2025-10-21T08:23:29.719Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/aa25bb0727e661a352d1c52e7288e25c12fe77047f988bb45557c17cf2d7/pyobjc_framework_usernotifications-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c62e8d7153d72c4379071e34258aa8b7263fa59212cfffd2f137013667e50381", size = 9632, upload-time = "2025-11-14T10:05:55.166Z" }, ] [[package]] name = "pyobjc-framework-usernotificationsui" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, { name = "pyobjc-framework-usernotifications" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/aa/07/e7564e9948ad5e834c394cb8b3cfba51312715a91f1cb0e01a9dcf8f5bc5/pyobjc_framework_usernotificationsui-12.0.tar.gz", hash = "sha256:b62eed9660a3b824dd732fca831f111b888af912c8608e0fe7e075de217274b8", size = 13148, upload-time = "2025-10-21T08:41:38.228Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/03/73e29fd5e5973cb3800c9d56107c1062547ef7524cbcc757c3cbbd5465c6/pyobjc_framework_usernotificationsui-12.1.tar.gz", hash = "sha256:51381c97c7344099377870e49ed0871fea85ba50efe50ab05ccffc06b43ec02e", size = 13125, upload-time = "2025-11-14T10:23:07.259Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/0f/79602271972bd1060e1ad24973d005be7984f7687278d4b2489021fe0f20/pyobjc_framework_usernotificationsui-12.0-py2.py3-none-any.whl", hash = "sha256:ab0d9fc8e9505daf15e089837125bedf9aec5fa5c49ba0ec91305fab3233977f", size = 3944, upload-time = "2025-10-21T08:23:39.959Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/52ac8a879079c1fbf25de8335ff506f7db87ff61e64838b20426f817f5d5/pyobjc_framework_usernotificationsui-12.1-py2.py3-none-any.whl", hash = "sha256:11af59dc5abfcb72c08769ab4d7ca32a628527a8ba341786431a0d2dacf31605", size = 3933, upload-time = "2025-11-14T10:06:05.478Z" }, ] [[package]] name = "pyobjc-framework-videosubscriberaccount" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/0f/ad63ee1b7b0813dd6505b210f90b9cd39d1e9b5a994c2e2d81e34ce045b0/pyobjc_framework_videosubscriberaccount-12.0.tar.gz", hash = "sha256:45ded32cd5d75323a3c9a692fe0f47fdda3885f16d84c0195908bfe0708db9e3", size = 18836, upload-time = "2025-10-21T08:41:40.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/f8/27927a9c125c622656ee5aada4596ccb8e5679da0260742360f193df6dcf/pyobjc_framework_videosubscriberaccount-12.1.tar.gz", hash = "sha256:750459fa88220ab83416f769f2d5d210a1f77b8938fa4d119aad0002fc32846b", size = 18793, upload-time = "2025-11-14T10:23:09.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/be/ff8942932b0ffe180b7f64fd15fb8503b846040af5a7aceae33a831f0aa3/pyobjc_framework_videosubscriberaccount-12.0-py2.py3-none-any.whl", hash = "sha256:18a495d747252712b65235f98459fec139966060a269eebf55cd56d159640663", size = 4834, upload-time = "2025-10-21T08:23:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/41/ca/e2f982916267508c1594f1e50d27bf223a24f55a5e175ab7d7822a00997c/pyobjc_framework_videosubscriberaccount-12.1-py2.py3-none-any.whl", hash = "sha256:381a5e8a3016676e52b88e38b706559fa09391d33474d8a8a52f20a883104a7b", size = 4825, upload-time = "2025-11-14T10:06:07.027Z" }, ] [[package]] name = "pyobjc-framework-videotoolbox" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -2937,27 +2966,27 @@ dependencies = [ { name = "pyobjc-framework-coremedia" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/2f/f85731e4f2ce2c67545dfbe2fbdd1b776b6e2d58e354a4037a2e59803fa0/pyobjc_framework_videotoolbox-12.0.tar.gz", hash = "sha256:69677923fa61fd2ca5acadb404e4be87185cd52946681764986bc43635d27674", size = 58211, upload-time = "2025-10-21T08:41:45.146Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/5f/6995ee40dc0d1a3460ee183f696e5254c0ad14a25b5bc5fd9bd7266c077b/pyobjc_framework_videotoolbox-12.1.tar.gz", hash = "sha256:7adc8670f3b94b086aed6e86c3199b388892edab4f02933c2e2d9b1657561bef", size = 57825, upload-time = "2025-11-14T10:23:13.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/2e/dfe3c5c7d4b50677d1aa2c6e52ce3757cdfab9a3427f4dca64590b2e80c0/pyobjc_framework_videotoolbox-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:49db730a3020acd1592b91ac224850ae79ce155343135f7f75eddcf1d77be405", size = 18790, upload-time = "2025-10-21T08:23:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/1e/42/53d57b09fd4879988084ec0d9b74c645c9fdd322be594c9601f6cf265dd0/pyobjc_framework_videotoolbox-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a1eb1eb41c0ffdd8dcc6a9b68ab2b5bc50824a85820c8a7802a94a22dfbb4f91", size = 18781, upload-time = "2025-11-14T10:06:11.89Z" }, ] [[package]] name = "pyobjc-framework-virtualization" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/53/cdba247e9b8252407757edd2e1a7f166b1c8e7a6edf54fc57aa55ca3e0b4/pyobjc_framework_virtualization-12.0.tar.gz", hash = "sha256:0745f57ab3010f10c6e7a424cbfc805f162167687756cce7ef220d1a4fc192cc", size = 41136, upload-time = "2025-10-21T08:41:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/6a/9d110b5521d9b898fad10928818c9f55d66a4af9ac097426c65a9878b095/pyobjc_framework_virtualization-12.1.tar.gz", hash = "sha256:e96afd8e801e92c6863da0921e40a3b68f724804f888bce43791330658abdb0f", size = 40682, upload-time = "2025-11-14T10:23:17.456Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/7e/9f37f76a4d0914911683399f12f947c5380484e7553dd535fdb406fba35c/pyobjc_framework_virtualization-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9f87fd04be9f40cb7f67eeb1783f7fab5b730042e16bc75873cc3c4c608ecb63", size = 13112, upload-time = "2025-10-21T08:24:02.222Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ee/e18d0d9014c42758d7169144acb2d37eb5ff19bf959db74b20eac706bd8c/pyobjc_framework_virtualization-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a88a307dc96885afc227ceda4067f1af787f024063f4ccf453d59e7afd47cda8", size = 13099, upload-time = "2025-11-14T10:06:27.403Z" }, ] [[package]] name = "pyobjc-framework-vision" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -2965,22 +2994,22 @@ dependencies = [ { name = "pyobjc-framework-coreml" }, { name = "pyobjc-framework-quartz" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/5a/07cdead5adb77d0742b014fa742d503706754e3ad10e39760e67bb58b497/pyobjc_framework_vision-12.0.tar.gz", hash = "sha256:942c9583f1d887ac9f704f3b0c21b3206b68e02852a87219db4309bb13a02f14", size = 59905, upload-time = "2025-10-21T08:41:53.741Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/5a/08bb3e278f870443d226c141af14205ff41c0274da1e053b72b11dfc9fb2/pyobjc_framework_vision-12.1.tar.gz", hash = "sha256:a30959100e85dcede3a786c544e621ad6eb65ff6abf85721f805822b8c5fe9b0", size = 59538, upload-time = "2025-11-14T10:23:21.979Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/e1/0e865d629a7aba0be220a49b59fa0ac2498c4a10d959288b8544da78d595/pyobjc_framework_vision-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbcba9cbe95116ad96aa05decd189735b213ffd8ee4ec0f81b197c3aaa0af87d", size = 21441, upload-time = "2025-10-21T08:24:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/e30cf4eef2b4c7e20ccadc1249117c77305fbc38b2e5904eb42e3753f63c/pyobjc_framework_vision-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1edbf2fc18ce3b31108f845901a88f2236783ae6bf0bc68438d7ece572dc2a29", size = 21432, upload-time = "2025-11-14T10:06:42.373Z" }, ] [[package]] name = "pyobjc-framework-webkit" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, { name = "pyobjc-framework-cocoa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/6a/9af14df620fd363e58d3676d7182060672f3eace49df78fc36ddbce9b820/pyobjc_framework_webkit-12.0.tar.gz", hash = "sha256:a65a33d7057aed8d096672be4a53a7ea49a7c74a0b4bc9cb216d4773ebfed6d2", size = 284938, upload-time = "2025-10-21T08:42:12.645Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/10/110a50e8e6670765d25190ca7f7bfeecc47ec4a8c018cb928f4f82c56e04/pyobjc_framework_webkit-12.1.tar.gz", hash = "sha256:97a54dd05ab5266bd4f614e41add517ae62cdd5a30328eabb06792474b37d82a", size = 284531, upload-time = "2025-11-14T10:23:40.287Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/8e/bf606a62aac481bfc46cbcd1faa540af6bf944cef52725dbc58238e0a361/pyobjc_framework_webkit-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:38171cb467ef46ea6a38bcf101bff2f67bc938326fca1a94161e12186ed39a33", size = 49981, upload-time = "2025-10-21T08:24:38.325Z" }, + { url = "https://files.pythonhosted.org/packages/e5/37/5082a0bbe12e48d4ffa53b0c0f09c77a4a6ffcfa119e26fa8dd77c08dc1c/pyobjc_framework_webkit-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3db734877025614eaef4504fadc0fbbe1279f68686a6f106f2e614e89e0d1a9d", size = 49970, upload-time = "2025-11-14T10:07:01.413Z" }, ] [[package]] @@ -3028,6 +3057,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, ] +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + [[package]] name = "pytest-randomly" version = "4.0.1" @@ -3040,6 +3081,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/3e/a4a9227807b56869790aad3e24472a554b585974fe7e551ea350f50897ae/pytest_randomly-4.0.1-py3-none-any.whl", hash = "sha256:e0dfad2fd4f35e07beff1e47c17fbafcf98f9bf4531fd369d9260e2f858bfcb7", size = 8304, upload-time = "2025-09-12T15:22:58.946Z" }, ] +[[package]] +name = "pytest-trio" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "outcome" }, + { name = "pytest" }, + { name = "trio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/08/056279526554c6c6e6ad6d4a479a338d14dc785ac30be8bdc6ca0153c1be/pytest-trio-0.8.0.tar.gz", hash = "sha256:8363db6336a79e6c53375a2123a41ddbeccc4aa93f93788651641789a56fb52e", size = 46525, upload-time = "2022-11-01T17:24:29.352Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/22/71953f47e0da5852c899f58cd7a31e6100f37c632b7b9ee52d067613a844/pytest_trio-0.8.0-py3-none-any.whl", hash = "sha256:e6a7e7351ae3e8ec3f4564d30ee77d1ec66e1df611226e5618dbb32f9545c841", size = 27221, upload-time = "2022-11-01T17:24:27.501Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -3123,6 +3178,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + [[package]] name = "ruff" version = "0.14.1" @@ -3168,15 +3236,6 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" } -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "shiboken6" version = "6.10.0" @@ -3233,21 +3292,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/5b/94237a3485620dbff9741df02ff6d8acaa5fdec67d81ab3f62e4d8511bf7/trio-0.31.0-py3-none-any.whl", hash = "sha256:b5d14cd6293d79298b49c3485ffd9c07e3ce03a6da8c7dfbe0cb3dd7dc9a4774", size = 512679, upload-time = "2025-09-09T15:17:13.821Z" }, ] -[[package]] -name = "typer" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, -] - [[package]] name = "types-cachetools" version = "6.2.0.20251022" From f7902562f6abf8860862c573e370c9198d29e57b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 5 Nov 2025 17:12:08 -0600 Subject: [PATCH 02/97] fix: FHS run --- flake.nix | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index aa9dfea8..4a8ce138 100644 --- a/flake.nix +++ b/flake.nix @@ -44,8 +44,8 @@ sourcePreference = "wheel"; }; pyprojectOverrides = final: prev: { - onelauncher = prev.onelauncher.overrideAttrs (old: { - passthru = old.passthru // { + onelauncher = prev.onelauncher.overrideAttrs (previousAttrs: { + passthru = previousAttrs.passthru // { # Add tests to Nix package tests = let @@ -53,7 +53,7 @@ onelauncher = [ "test" ]; }; in - (old.tests or { }) + (previousAttrs.tests or { }) // { pytest = pkgs.stdenv.mkDerivation { name = "${final.onelauncher.name}-pytest"; @@ -180,10 +180,9 @@ }; default = self.packages.${system}.onelauncher; fhs-run = - (pkgs.steam-fhsenv-without-steam.override { - # steam-unwrapped = null; + (pkgs.steam.override { extraPkgs = pkgs: [ pkgs.libz ]; - }).run; + }).run-free; }; apps = { onelauncher = flake-utils.lib.mkApp { drv = self.packages.${system}.onelauncher; }; From a3eac031be6a3ef1ef030c89fd9ac3a952b5bf3b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 6 Nov 2025 11:13:27 -0600 Subject: [PATCH 03/97] feat: Add `log_verbosity` option --- README.md | 3 +++ src/onelauncher/cli.py | 16 ++++++++++++++-- src/onelauncher/config_manager.py | 28 +++++++++++++++++++++++++--- src/onelauncher/logs.py | 30 +++++++++++++++++++++--------- src/onelauncher/program_config.py | 5 +++++ 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b0b44d83..b88430a3 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,9 @@ set with ONELAUNCHER_CONFIG_DIRECTORY. │ ault-locale-for-ui │ │ --games-sorting-mode Order to show games in UI [choices: priority, │ │ last-played, alphabetical] │ +│ --log-verbosity Minimum log severity that will be shown in the │ +│ console and log file [choices: debug, info, │ +│ warning, error, critical] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Game Options ───────────────────────────────────────────────────────────────╮ │ --game-directory The game's install directory │ diff --git a/src/onelauncher/cli.py b/src/onelauncher/cli.py index 32741fa5..cdbfa332 100644 --- a/src/onelauncher/cli.py +++ b/src/onelauncher/cli.py @@ -40,7 +40,7 @@ ) from .game_account_config import GameAccountConfig, GameAccountsConfig from .game_config import ClientType, GameConfig, GameConfigID, GameType -from .logs import setup_application_logging +from .logs import LogLevel, setup_application_logging from .program_config import GamesSortingMode, ProgramConfig from .resources import OneLauncherLocale from .ui import qtdesigner @@ -80,6 +80,7 @@ def _merge_program_config( default_locale: OneLauncherLocale | None, always_use_default_locale_for_ui: bool | None, games_sorting_mode: GamesSortingMode | None, + log_verbosity: LogLevel | None, ) -> ProgramConfig: """ Merge `program_config` with CLI options. Any specified CLI options will @@ -94,6 +95,9 @@ def _merge_program_config( else program_config.always_use_default_locale_for_ui ), games_sorting_mode=(games_sorting_mode or program_config.games_sorting_mode), + log_verbosity=( + log_verbosity if log_verbosity is not None else program_config.log_verbosity + ), ) @@ -365,6 +369,11 @@ def meta( GamesSortingMode | None, Parameter(group=ProgramGroup, help=prog_help("games_sorting_mode")), ] = None, + log_verbosity: Annotated[ + LogLevel | None, + Parameter(group=ProgramGroup, help=prog_help("log_verbosity")), + ] = None, + # Game game: Annotated[ _GameParamGameType | GameConfigID | None, Parameter( @@ -380,6 +389,7 @@ def meta( default_locale=default_locale, always_use_default_locale_for_ui=always_use_default_locale_for_ui, games_sorting_mode=games_sorting_mode, + log_verbosity=log_verbosity, ) nonlocal _game_id if game is None: @@ -497,7 +507,9 @@ def default( str | None, Parameter(group=WineGroup, help=wine_help("debug_level")) ] = None, ) -> int: - setup_application_logging() + setup_application_logging( + log_level_override=config_manager.get_program_config().log_verbosity + ) config_manager.get_merged_game_config = partial( _merge_game_config, game_directory=game_directory, diff --git a/src/onelauncher/config_manager.py b/src/onelauncher/config_manager.py index af1348fa..308705fc 100644 --- a/src/onelauncher/config_manager.py +++ b/src/onelauncher/config_manager.py @@ -14,6 +14,8 @@ from packaging.version import InvalidVersion, Version from tomlkit.items import Comment, Table, Whitespace +from onelauncher.logs import LogLevel + from .__about__ import __title__ from .addons.startup_script import StartupScript from .config import Config, ConfigValWithMetadata, platform_dirs, unstructure_config @@ -47,18 +49,38 @@ def _unstructure_onelauncher_locale(locale: OneLauncherLocale) -> str: return locale.lang_tag +def _unstructure_log_level(log_level: LogLevel) -> str: + return log_level.name.lower() + + +def _structure_log_level( + log_level_name: str, conversion_type: type[LogLevel] +) -> LogLevel: + try: + return LogLevel[log_level_name.upper()] + except KeyError as e: + raise ValueError( + "Invalid log level name. Valid options are: " + f"{[name.lower() for name in LogLevel._member_names_]}. " + f"Value provided was: {log_level_name}" + ) from e + + @cache def get_converter() -> cattrs.Converter: converter = make_converter() + converter.register_unstructure_hook( + OneLauncherLocale, _unstructure_onelauncher_locale + ) converter.register_structure_hook(OneLauncherLocale, _structure_onelauncher_locale) converter.register_unstructure_hook(StartupScript, _unstructure_startup_script) converter.register_structure_hook(StartupScript, _structure_startup_script) - converter.register_unstructure_hook( - OneLauncherLocale, _unstructure_onelauncher_locale - ) + converter.register_unstructure_hook(LogLevel, _unstructure_log_level) + converter.register_structure_hook(LogLevel, _structure_log_level) + converter.register_unstructure_hook_func( check_func=attrs.has, func=partial(unstructure_config, converter) ) diff --git a/src/onelauncher/logs.py b/src/onelauncher/logs.py index 3ab73e55..c9fb738c 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -1,6 +1,7 @@ import logging import sys from collections.abc import Callable +from enum import IntEnum from functools import partial from logging.handlers import RotatingFileHandler from pathlib import Path @@ -17,6 +18,14 @@ MAIN_LOG_FILE_NAME = "main.log" +class LogLevel(IntEnum): + DEBUG = logging.DEBUG + INFO = logging.INFO + WARNING = logging.WARNING + ERROR = logging.ERROR + CRITICAL = logging.CRITICAL + + def log_basic_info(logger: logging.Logger) -> None: logger.info("Logging started") logger.info(f"{__title__}: {__version__}") @@ -51,17 +60,20 @@ def format(self, record: logging.LogRecord) -> str: return unredacted.replace(str(Path.home()), "") -def setup_application_logging() -> None: +def setup_application_logging(log_level_override: LogLevel | None = None) -> None: """Create root logger configured for running application""" - if version_parsed.is_devrelease: - file_logging_level = logging.DEBUG - stream_logging_level = logging.DEBUG + if log_level_override is not None: + file_logging_level = log_level_override + stream_logging_level = log_level_override + elif version_parsed.is_devrelease: + file_logging_level = LogLevel.DEBUG + stream_logging_level = LogLevel.INFO elif version_parsed.is_prerelease: - file_logging_level = logging.DEBUG - stream_logging_level = logging.WARNING + file_logging_level = LogLevel.DEBUG + stream_logging_level = LogLevel.WARNING else: - file_logging_level = logging.INFO - stream_logging_level = logging.WARNING + file_logging_level = LogLevel.INFO + stream_logging_level = LogLevel.WARNING # Make sure logs dir exists LOGS_DIR.mkdir(exist_ok=True, parents=True) @@ -71,7 +83,7 @@ def setup_application_logging() -> None: # This is for the logger globally. Different handlers # attached to it have their own levels. - logger.setLevel(logging.DEBUG) + logger.setLevel(LogLevel.DEBUG) # Create handlers stream_handler = logging.StreamHandler() diff --git a/src/onelauncher/program_config.py b/src/onelauncher/program_config.py index 1035ab81..bcbc1c37 100644 --- a/src/onelauncher/program_config.py +++ b/src/onelauncher/program_config.py @@ -6,6 +6,7 @@ from .__about__ import __title__ from .config import Config, config_field +from .logs import LogLevel from .resources import ( OneLauncherLocale, get_default_locale, @@ -36,6 +37,10 @@ class ProgramConfig(Config): games_sorting_mode: GamesSortingMode = config_field( default=GamesSortingMode.PRIORITY, help="Order to show games in UI" ) + log_verbosity: LogLevel | None = config_field( + default=None, + help="Minimum log severity that will be shown in the console and log file", + ) @override @staticmethod From 48f3d577bfbd49231804fc4a220b28b4ec5855c0 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 6 Nov 2025 11:33:34 -0600 Subject: [PATCH 04/97] feat: Sort old worlds to bottom of worlds list Resolves #82 --- src/onelauncher/main_window.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index e9cb6378..ce16fcac 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -855,7 +855,11 @@ async def game_initial_network_setup(self) -> None: await self.load_newsfeed(self.game_launcher_config) def load_worlds_list(self, game_services_info: GameServicesInfo) -> None: - sorted_worlds = sorted(game_services_info.worlds, key=lambda world: world.name) + # Sort alphabetically with old worlds at the bottom. + sorted_worlds = sorted( + game_services_info.worlds, + key=lambda world: f"{2 if world.name.strip().lower().endswith('[old]') else 1}{world.name}", + ) for world in sorted_worlds: self.ui.cboWorld.addItem(world.name, userData=world) From ce6f4438ed57c9526204c5d323c22f169abef3f3 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 6 Nov 2025 12:15:27 -0600 Subject: [PATCH 05/97] fix: Improve UI label buddies and tab order --- src/onelauncher/ui/addon_manager.ui | 13 +++++ src/onelauncher/ui/addon_manager_uic.py | 16 ++++-- src/onelauncher/ui/main.ui | 16 +++--- src/onelauncher/ui/main_uic.py | 32 +++++------ src/onelauncher/ui/select_subscription.ui | 3 ++ src/onelauncher/ui/select_subscription_uic.py | 5 +- src/onelauncher/ui/settings.ui | 54 +++++++++++++++++++ src/onelauncher/ui/settings_uic.py | 24 ++++++++- src/onelauncher/ui/setup_wizard.ui | 6 +++ src/onelauncher/ui/setup_wizard_uic.py | 14 +++-- 10 files changed, 150 insertions(+), 33 deletions(-) diff --git a/src/onelauncher/ui/addon_manager.ui b/src/onelauncher/ui/addon_manager.ui index cad710e9..1955eec2 100644 --- a/src/onelauncher/ui/addon_manager.ui +++ b/src/onelauncher/ui/addon_manager.ui @@ -699,6 +699,19 @@ 1 + + txtSearchBar + tablePluginsInstalled + tableSkinsInstalled + tableMusicInstalled + tablePlugins + tableSkins + tableMusic + btnAddons + btnUpdateAll + btnCheckForUpdates + btnCheckForUpdates_2 + diff --git a/src/onelauncher/ui/addon_manager_uic.py b/src/onelauncher/ui/addon_manager_uic.py index 7d7d1dc1..062e8fce 100644 --- a/src/onelauncher/ui/addon_manager_uic.py +++ b/src/onelauncher/ui/addon_manager_uic.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'addon_manager.ui' ## -## Created by: Qt User Interface Compiler version 6.7.2 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -330,6 +330,16 @@ def setupUi(self, winAddonManager: QWidgetWithStylePreview) -> None: self.verticalLayout_9.addWidget(self.progressBar) + QWidget.setTabOrder(self.txtSearchBar, self.tablePluginsInstalled) + QWidget.setTabOrder(self.tablePluginsInstalled, self.tableSkinsInstalled) + QWidget.setTabOrder(self.tableSkinsInstalled, self.tableMusicInstalled) + QWidget.setTabOrder(self.tableMusicInstalled, self.tablePlugins) + QWidget.setTabOrder(self.tablePlugins, self.tableSkins) + QWidget.setTabOrder(self.tableSkins, self.tableMusic) + QWidget.setTabOrder(self.tableMusic, self.btnAddons) + QWidget.setTabOrder(self.btnAddons, self.btnUpdateAll) + QWidget.setTabOrder(self.btnUpdateAll, self.btnCheckForUpdates) + QWidget.setTabOrder(self.btnCheckForUpdates, self.btnCheckForUpdates_2) self.retranslateUi(winAddonManager) @@ -365,7 +375,7 @@ def retranslateUi(self, winAddonManager: QWidgetWithStylePreview) -> None: #if QT_CONFIG(tooltip) self.btnAddons.setToolTip(QCoreApplication.translate("winAddonManager", u"Remove addons", None)) #endif // QT_CONFIG(tooltip) - self.btnAddons.setProperty("qssClass", [ + self.btnAddons.setProperty(u"qssClass", [ QCoreApplication.translate("winAddonManager", u"icon-lg", None), QCoreApplication.translate("winAddonManager", u"px-2.5", None), QCoreApplication.translate("winAddonManager", u"py-1", None)]) @@ -379,7 +389,7 @@ def retranslateUi(self, winAddonManager: QWidgetWithStylePreview) -> None: #if QT_CONFIG(tooltip) self.btnCheckForUpdates_2.setToolTip(QCoreApplication.translate("winAddonManager", u"Check for updates", None)) #endif // QT_CONFIG(tooltip) - self.progressBar.setProperty("qssClass", [ + self.progressBar.setProperty(u"qssClass", [ QCoreApplication.translate("winAddonManager", u"max-h-2", None)]) # retranslateUi diff --git a/src/onelauncher/ui/main.ui b/src/onelauncher/ui/main.ui index 1c89bba4..a5442df2 100644 --- a/src/onelauncher/ui/main.ui +++ b/src/onelauncher/ui/main.ui @@ -233,6 +233,9 @@ World + + cboWorld + @@ -275,6 +278,9 @@ Account + + cboAccount + @@ -292,6 +298,9 @@ Password + + txtPassword + @@ -461,15 +470,10 @@ cboWorld cboAccount txtPassword + btnLogin chkSaveAccount chkSavePassword txtFeed - btnMinimize - btnSwitchGame - btnOptions - btnExit - btnAddonManager - btnAbout txtStatus diff --git a/src/onelauncher/ui/main_uic.py b/src/onelauncher/ui/main_uic.py index b916ef13..6f572818 100644 --- a/src/onelauncher/ui/main_uic.py +++ b/src/onelauncher/ui/main_uic.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'main.ui' ## -## Created by: Qt User Interface Compiler version 6.7.2 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -236,18 +236,18 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: self.verticalLayout_4.addLayout(self.horizontalLayout_4) winMain.setCentralWidget(self.centralwidget) +#if QT_CONFIG(shortcut) + self.lblWorld.setBuddy(self.cboWorld) + self.lblAccount.setBuddy(self.cboAccount) + self.lblPassword.setBuddy(self.txtPassword) +#endif // QT_CONFIG(shortcut) QWidget.setTabOrder(self.cboWorld, self.cboAccount) QWidget.setTabOrder(self.cboAccount, self.txtPassword) - QWidget.setTabOrder(self.txtPassword, self.chkSaveAccount) + QWidget.setTabOrder(self.txtPassword, self.btnLogin) + QWidget.setTabOrder(self.btnLogin, self.chkSaveAccount) QWidget.setTabOrder(self.chkSaveAccount, self.chkSavePassword) QWidget.setTabOrder(self.chkSavePassword, self.txtFeed) - QWidget.setTabOrder(self.txtFeed, self.btnMinimize) - QWidget.setTabOrder(self.btnMinimize, self.btnSwitchGame) - QWidget.setTabOrder(self.btnSwitchGame, self.btnOptions) - QWidget.setTabOrder(self.btnOptions, self.btnExit) - QWidget.setTabOrder(self.btnExit, self.btnAddonManager) - QWidget.setTabOrder(self.btnAddonManager, self.btnAbout) - QWidget.setTabOrder(self.btnAbout, self.txtStatus) + QWidget.setTabOrder(self.txtFeed, self.txtStatus) self.retranslateUi(winMain) @@ -265,27 +265,27 @@ def retranslateUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: #if QT_CONFIG(tooltip) self.btnOptions.setToolTip(QCoreApplication.translate("winMain", u"Settings", None)) #endif // QT_CONFIG(tooltip) - self.btnOptions.setProperty("qssClass", [ + self.btnOptions.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"icon-lg", None)]) #if QT_CONFIG(tooltip) self.btnAddonManager.setToolTip(QCoreApplication.translate("winMain", u"Addon manager", None)) #endif // QT_CONFIG(tooltip) - self.btnAddonManager.setProperty("qssClass", [ + self.btnAddonManager.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"icon-lg", None)]) #if QT_CONFIG(tooltip) self.btnAbout.setToolTip(QCoreApplication.translate("winMain", u"About", None)) #endif // QT_CONFIG(tooltip) - self.btnAbout.setProperty("qssClass", [ + self.btnAbout.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"icon-lg", None)]) #if QT_CONFIG(tooltip) self.btnMinimize.setToolTip(QCoreApplication.translate("winMain", u"Minimize", None)) #endif // QT_CONFIG(tooltip) - self.btnMinimize.setProperty("qssClass", [ + self.btnMinimize.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"icon-lg", None)]) #if QT_CONFIG(tooltip) self.btnExit.setToolTip(QCoreApplication.translate("winMain", u"Exit", None)) #endif // QT_CONFIG(tooltip) - self.btnExit.setProperty("qssClass", [ + self.btnExit.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"icon-lg", None)]) #if QT_CONFIG(tooltip) self.lblWorld.setToolTip(QCoreApplication.translate("winMain", u"Game server", None)) @@ -297,7 +297,7 @@ def retranslateUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: #if QT_CONFIG(tooltip) self.btnSwitchGame.setToolTip(QCoreApplication.translate("winMain", u"Switch game", None)) #endif // QT_CONFIG(tooltip) - self.btnSwitchGame.setProperty("qssClass", [ + self.btnSwitchGame.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"icon-xl", None)]) self.lblAccount.setText(QCoreApplication.translate("winMain", u"Account", None)) self.lblPassword.setText(QCoreApplication.translate("winMain", u"Password", None)) @@ -305,7 +305,7 @@ def retranslateUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: self.btnLogin.setToolTip(QCoreApplication.translate("winMain", u"Start your adventure!", None)) #endif // QT_CONFIG(tooltip) self.btnLogin.setText(QCoreApplication.translate("winMain", u"Play", None)) - self.btnLogin.setProperty("qssClass", [ + self.btnLogin.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"text-xl", None), QCoreApplication.translate("winMain", u"px-3.5", None), QCoreApplication.translate("winMain", u"py-2", None), diff --git a/src/onelauncher/ui/select_subscription.ui b/src/onelauncher/ui/select_subscription.ui index d5d8bfc7..6202e5ba 100644 --- a/src/onelauncher/ui/select_subscription.ui +++ b/src/onelauncher/ui/select_subscription.ui @@ -33,6 +33,9 @@ Please select one Qt::AlignmentFlag::AlignCenter + + subscriptionsComboBox + diff --git a/src/onelauncher/ui/select_subscription_uic.py b/src/onelauncher/ui/select_subscription_uic.py index b1217c4d..34816566 100644 --- a/src/onelauncher/ui/select_subscription_uic.py +++ b/src/onelauncher/ui/select_subscription_uic.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'select_subscription.ui' ## -## Created by: Qt User Interface Compiler version 6.7.2 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -47,6 +47,9 @@ def setupUi(self, dlgSelectSubscription: QDialog) -> None: self.verticalLayout.addWidget(self.buttonBox) +#if QT_CONFIG(shortcut) + self.label.setBuddy(self.subscriptionsComboBox) +#endif // QT_CONFIG(shortcut) self.retranslateUi(dlgSelectSubscription) self.buttonBox.accepted.connect(dlgSelectSubscription.accept) diff --git a/src/onelauncher/ui/settings.ui b/src/onelauncher/ui/settings.ui index f0a04a61..819fcf49 100644 --- a/src/onelauncher/ui/settings.ui +++ b/src/onelauncher/ui/settings.ui @@ -62,6 +62,9 @@ Name + + gameNameLineEdit + @@ -72,6 +75,9 @@ Config ID + + gameConfigIDLineEdit + @@ -86,6 +92,9 @@ Description + + gameDescriptionLineEdit + @@ -96,6 +105,9 @@ Newsfeed URL + + gameNewsfeedLineEdit + @@ -112,6 +124,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + gameDirLineEdit + @@ -191,6 +206,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + gameLanguageComboBox + @@ -211,6 +229,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + highResCheckBox + @@ -240,6 +261,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + clientTypeComboBox + @@ -260,6 +284,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + standardLauncherLineEdit + @@ -296,6 +323,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + patchClientLineEdit + @@ -326,6 +356,9 @@ Settings Directory + + gameSettingsDirLineEdit + @@ -384,6 +417,9 @@ Auto Manage Wine + + autoManageWineCheckBox + @@ -404,6 +440,9 @@ Wine Prefix + + winePrefixLineEdit + @@ -424,6 +463,9 @@ Wine Executable + + wineExecutableLineEdit + @@ -444,6 +486,9 @@ WINEDEBUG + + wineDebugLineEdit + @@ -496,6 +541,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + defaultLanguageComboBox + @@ -525,6 +573,9 @@ true + + defaultLanguageForUICheckBox + @@ -551,6 +602,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + gamesSortingModeComboBox + diff --git a/src/onelauncher/ui/settings_uic.py b/src/onelauncher/ui/settings_uic.py index f5560ee0..1bbfbfd5 100644 --- a/src/onelauncher/ui/settings_uic.py +++ b/src/onelauncher/ui/settings_uic.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'settings.ui' ## -## Created by: Qt User Interface Compiler version 6.7.2 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -22,7 +22,7 @@ QStackedWidget, QTabBar, QToolButton, QVBoxLayout, QWidget) -from .custom_widgets import FramelessQDialogWithStylePreview, FixedWordWrapQLabel +from .custom_widgets import (FixedWordWrapQLabel, FramelessQDialogWithStylePreview) from .qtdesigner.custom_widgets import QDialogWithStylePreview class Ui_dlgSettings(object): @@ -373,6 +373,26 @@ def setupUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: self.verticalLayout.addLayout(self.horizontalLayout) +#if QT_CONFIG(shortcut) + self.gameNameLabel.setBuddy(self.gameNameLineEdit) + self.gameConfigIDLabel.setBuddy(self.gameConfigIDLineEdit) + self.gameDescriptionLabel.setBuddy(self.gameDescriptionLineEdit) + self.gameNewsfeedLabel.setBuddy(self.gameNewsfeedLineEdit) + self.gameDirLabel.setBuddy(self.gameDirLineEdit) + self.gameLanguageLabel.setBuddy(self.gameLanguageComboBox) + self.highResLabel.setBuddy(self.highResCheckBox) + self.clientLabel.setBuddy(self.clientTypeComboBox) + self.standardLauncherLabel.setBuddy(self.standardLauncherLineEdit) + self.patchClientLabel.setBuddy(self.patchClientLineEdit) + self.gameSettingsDirLabel.setBuddy(self.gameSettingsDirLineEdit) + self.autoManageWineLabel.setBuddy(self.autoManageWineCheckBox) + self.winePrefixLabel.setBuddy(self.winePrefixLineEdit) + self.wineExecutableLabel.setBuddy(self.wineExecutableLineEdit) + self.wineDebugLabel.setBuddy(self.wineDebugLineEdit) + self.defaultLanguageLabel.setBuddy(self.defaultLanguageComboBox) + self.defaultLanguageForUILabel.setBuddy(self.defaultLanguageForUICheckBox) + self.gamesSortingModeLabel.setBuddy(self.gamesSortingModeComboBox) +#endif // QT_CONFIG(shortcut) self.retranslateUi(dlgSettings) self.settingsButtonBox.rejected.connect(dlgSettings.reject) diff --git a/src/onelauncher/ui/setup_wizard.ui b/src/onelauncher/ui/setup_wizard.ui index 35017e63..c4f131ec 100644 --- a/src/onelauncher/ui/setup_wizard.ui +++ b/src/onelauncher/ui/setup_wizard.ui @@ -37,6 +37,9 @@ Qt::AlignmentFlag::AlignCenter + + languagesListWidget + @@ -101,6 +104,9 @@ Always Use Default Language For UI + + alwaysUseDefaultLangForUICheckBox + diff --git a/src/onelauncher/ui/setup_wizard_uic.py b/src/onelauncher/ui/setup_wizard_uic.py index 01d74dbd..05923679 100644 --- a/src/onelauncher/ui/setup_wizard_uic.py +++ b/src/onelauncher/ui/setup_wizard_uic.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'setup_wizard.ui' ## -## Created by: Qt User Interface Compiler version 6.7.2 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -47,7 +47,7 @@ def setupUi(self, Wizard: QWizard) -> None: self.languagesListWidget.setSizePolicy(sizePolicy) self.languagesListWidget.setFrameShape(QFrame.Shape.Box) self.languagesListWidget.setEditTriggers(QAbstractItemView.EditTrigger.CurrentChanged|QAbstractItemView.EditTrigger.DoubleClicked|QAbstractItemView.EditTrigger.EditKeyPressed|QAbstractItemView.EditTrigger.SelectedClicked) - self.languagesListWidget.setProperty("showDropIndicator", False) + self.languagesListWidget.setProperty(u"showDropIndicator", False) self.languagesListWidget.setWordWrap(True) self.languagesListWidget.setSortingEnabled(True) @@ -161,7 +161,7 @@ def setupUi(self, Wizard: QWizard) -> None: self.gamesDeletionStatusListView = QListView(self.dataDeletionWizardPage) self.gamesDeletionStatusListView.setObjectName(u"gamesDeletionStatusListView") - self.gamesDeletionStatusListView.setProperty("showDropIndicator", False) + self.gamesDeletionStatusListView.setProperty(u"showDropIndicator", False) self.gamesDeletionStatusListView.setAlternatingRowColors(True) self.gamesDeletionStatusListView.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) @@ -171,6 +171,10 @@ def setupUi(self, Wizard: QWizard) -> None: self.finishedWizardPage = QWizardPage() self.finishedWizardPage.setObjectName(u"finishedWizardPage") Wizard.setPage(3, self.finishedWizardPage) +#if QT_CONFIG(shortcut) + self.label.setBuddy(self.languagesListWidget) + self.alwaysUseDefaultLangForUILabel.setBuddy(self.alwaysUseDefaultLangForUICheckBox) +#endif // QT_CONFIG(shortcut) self.retranslateUi(Wizard) @@ -194,7 +198,7 @@ def retranslateUi(self, Wizard: QWizard) -> None: self.alwaysUseDefaultLangForUILabel.setText(QCoreApplication.translate("Wizard", u"Always Use Default Language For UI", None)) self.gamesSelectionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Games Selection", None)) self.gamesSelectionWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"Select your game installations. The first one will be the main game instance.", None)) - self.gamesListWidget.setProperty("qssClass", [ + self.gamesListWidget.setProperty(u"qssClass", [ QCoreApplication.translate("Wizard", u"icon-xl", None)]) self.gamesDiscoveryStatusLabel.setText("") #if QT_CONFIG(tooltip) @@ -211,7 +215,7 @@ def retranslateUi(self, Wizard: QWizard) -> None: self.groupBox.setTitle(QCoreApplication.translate("Wizard", u"What should happen to existing game data?", None)) self.keepDataRadioButton.setText(QCoreApplication.translate("Wizard", u"Keep it", None)) self.resetDataRadioButton.setText(QCoreApplication.translate("Wizard", u"Reset it", None)) - self.gamesDeletionStatusListView.setProperty("qssClass", [ + self.gamesDeletionStatusListView.setProperty(u"qssClass", [ QCoreApplication.translate("Wizard", u"icon-xl", None)]) self.finishedWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Setup Finished", None)) self.finishedWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"That's it! You can always check out the settings menu or addons manager for extra customization.", None)) From 3ae41a11a59ccfb697598970c401f31c873d97df Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 6 Nov 2025 12:48:26 -0600 Subject: [PATCH 06/97] docs: Remove README banner image in BBCode conversion --- build/__main__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build/__main__.py b/build/__main__.py index d9d267bc..280d5ee1 100644 --- a/build/__main__.py +++ b/build/__main__.py @@ -1,3 +1,4 @@ +import re import sys from pathlib import Path @@ -6,9 +7,13 @@ out_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "out" out_dir.mkdir(exist_ok=True) -bbcode_readme = convert_readme_to_bbcode.convert( - (Path(__file__).parent.parent / "README.md").read_text(), +markdown_readme = (Path(__file__).parent.parent / "README.md").read_text() +# Remove window examples banner image, since LotroInterface and NexusMods have their +# own place to upload images, and Imgur doesn't work in the UK. +markdown_readme = re.sub( + r"\!\[OneLauncher window examples\]\(.*\)", "", markdown_readme ) +bbcode_readme = convert_readme_to_bbcode.convert(markdown_readme) (out_dir / "README_BBCode.txt").write_text(bbcode_readme) nuitka_compile.main( From 072eb972bad7f092fa0b2f3e0758306b4af1cb09 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 6 Nov 2025 12:49:28 -0600 Subject: [PATCH 07/97] fix: Include system environment when starting standard game launcher --- src/onelauncher/settings_window.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 77d0309f..3386c17f 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -340,6 +340,7 @@ async def run_standard_game_launcher(self, disable_patching: bool = False) -> No return process = QtCore.QProcess() + process.setProcessEnvironment(QtCore.QProcessEnvironment.systemEnvironment()) process.setWorkingDirectory(str(game_config.game_directory)) process.setProgram(str(launcher_path)) if disable_patching: From 052903fe03ac5fd7c4ebc9af7a37856bd26dd35b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 6 Nov 2025 19:10:29 -0600 Subject: [PATCH 08/97] fix: styling on MacOS --- src/onelauncher/qtapp.py | 4 ++++ src/onelauncher/ui/main.ui | 3 --- src/onelauncher/ui/main_uic.py | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/onelauncher/qtapp.py b/src/onelauncher/qtapp.py index cf2e0eeb..46f15127 100644 --- a/src/onelauncher/qtapp.py +++ b/src/onelauncher/qtapp.py @@ -63,6 +63,10 @@ def _setup_qapplication() -> QtWidgets.QApplication: if os.name == "nt": application.setStyle("Fusion") + # The Qt "Mac" style messes up bunch of alignment and styling. + if os.name == "nt" or sys.platform == "darwin": + application.setStyle("Fusion") + def set_qtawesome_defaults() -> None: qtawesome.reset_cache() qtawesome.set_defaults(color=application.palette().windowText().color()) diff --git a/src/onelauncher/ui/main.ui b/src/onelauncher/ui/main.ui index a5442df2..cfc8ff83 100644 --- a/src/onelauncher/ui/main.ui +++ b/src/onelauncher/ui/main.ui @@ -10,9 +10,6 @@ 470 - - false - diff --git a/src/onelauncher/ui/main_uic.py b/src/onelauncher/ui/main_uic.py index 6f572818..3c697e79 100644 --- a/src/onelauncher/ui/main_uic.py +++ b/src/onelauncher/ui/main_uic.py @@ -29,7 +29,6 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: if not winMain.objectName(): winMain.setObjectName(u"winMain") winMain.resize(790, 470) - winMain.setUnifiedTitleAndToolBarOnMac(False) self.actionPatch = QAction(winMain) self.actionPatch.setObjectName(u"actionPatch") self.actionLOTRO = QAction(winMain) From 0671fb906a83a3edf84b01ac82b950097e71911a Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 7 Nov 2025 09:45:01 -0600 Subject: [PATCH 09/97] fix: Don't pass sys.argv to QApplication. The CLI doesn't accept extra arguments, so this can only cause unexpected behavior. --- src/onelauncher/qtapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onelauncher/qtapp.py b/src/onelauncher/qtapp.py index 46f15127..a5188fde 100644 --- a/src/onelauncher/qtapp.py +++ b/src/onelauncher/qtapp.py @@ -41,7 +41,7 @@ @cache def _setup_qapplication() -> QtWidgets.QApplication: - application = QtWidgets.QApplication(sys.argv) + application = QtWidgets.QApplication() # See https://github.com/zhiyiYo/PyQt-Frameless-Window/issues/50 application.setAttribute( QtCore.Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings From cbce2dca00889ed8ab35495b0c59b5ef9389074c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 8 Nov 2025 00:23:25 -0600 Subject: [PATCH 10/97] feat: update WINE and DXVK, switch to WINE wow64 --- src/onelauncher/wine_environment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index a5ce8057..325227b1 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -51,9 +51,9 @@ # To use Proton, replace link with Proton build and uncomment # `self.proton_documents_symlinker()` in wine_setup in wine_management -WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.8/wine-10.8-staging-tkg-amd64.tar.xz" +WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.19/wine-10.19-staging-tkg-amd64-wow64.tar.xz" DXVK_URL = ( - "https://github.com/doitsujin/dxvk/releases/download/v2.6.1/dxvk-2.6.1.tar.gz" + "https://github.com/doitsujin/dxvk/releases/download/v2.7.1/dxvk-2.7.1.tar.gz" ) From e5daffc15af4808ccfd0ba6813bdec1ddbf528b3 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 8 Nov 2025 00:24:13 -0600 Subject: [PATCH 11/97] fix: make game started log consistent with others --- src/onelauncher/ui/start_game_window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onelauncher/ui/start_game_window.py b/src/onelauncher/ui/start_game_window.py index 79d21ecd..5c0b3c80 100644 --- a/src/onelauncher/ui/start_game_window.py +++ b/src/onelauncher/ui/start_game_window.py @@ -204,6 +204,6 @@ async def start_game(self) -> None: self.process_logging_adapter.extra = { ExternalProcessLogsFilter.EXTERNAL_PROCESS_ID_KEY: self.process.processId() } - logger.info("Game started") + logger.info("*** Started ***") self.exec() From e45debd3929017d1cf38a1a11d683c1732e37f89 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 8 Nov 2025 00:26:00 -0600 Subject: [PATCH 12/97] feat: Mac WINE support --- README.md | 2 +- src/onelauncher/wine_environment.py | 229 ++++++++++++++-------------- 2 files changed, 119 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index b88430a3..4cc0d0da 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ An enhanced launcher for both [LOTRO](https://www.lotro.com/) and [DDO](https:// - Password saving - Plugins, skins, and music manager - External scripting support for addons -- Auto WINE setup for Linux +- Auto WINE setup for Mac and Linux - Multiple clients support - *more* diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 325227b1..9fb4ce94 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -30,10 +30,13 @@ import lzma import os import ssl +import sys import tarfile from functools import partial from pathlib import Path from shutil import move, rmtree +from tempfile import TemporaryDirectory +from typing import Final from urllib import request from urllib.error import HTTPError, URLError @@ -49,12 +52,21 @@ logger = logging.getLogger(__name__) -# To use Proton, replace link with Proton build and uncomment -# `self.proton_documents_symlinker()` in wine_setup in wine_management -WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.19/wine-10.19-staging-tkg-amd64-wow64.tar.xz" -DXVK_URL = ( - "https://github.com/doitsujin/dxvk/releases/download/v2.7.1/dxvk-2.7.1.tar.gz" -) + +if sys.platform == "darwin": + WINE_VERSION = "staging-10.20" + WINE_URL = "https://github.com/Gcenx/macOS_Wine_builds/releases/download/10.20/wine-staging-10.20-osx64.tar.xz" + DXVK_VERSION = "1.10.3-20230507-repack" + DXVK_URL = "https://github.com/Gcenx/DXVK-macOS/releases/download/v1.10.3-20230507-repack/dxvk-macOS-async-v1.10.3-20230507-repack.tar.gz" +else: + # To use Proton, replace link with Proton build and uncomment + # `self.proton_documents_symlinker()` in wine_setup in wine_management + WINE_VERSION = "10.19-staging-tkg-amd64-wow64" + WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.19/wine-10.19-staging-tkg-amd64-wow64.tar.xz" + DXVK_VERSION = "2.7.1" + DXVK_URL = ( + "https://github.com/doitsujin/dxvk/releases/download/v2.7.1/dxvk-2.7.1.tar.gz" + ) @attrs.define @@ -69,9 +81,25 @@ class WineManagement: def __init__(self) -> None: self.is_setup = False - self.wine_path: Path | None = None - self.prefix_path = platform_dirs.user_cache_path / "wine/prefix" - self.downloads_path = platform_dirs.user_data_path / "wine" + self.prefix_path: Final[Path] = platform_dirs.user_cache_path / "wine/prefix" + self.prefix_system32: Final[Path] = ( + self.prefix_path / "drive_c/windows/system32" + ) + self.prefix_syswow64: Final[Path] = ( + self.prefix_path / "drive_c/windows/syswow64" + ) + + self.downloads_path: Final[Path] = platform_dirs.user_data_path / "wine" + + self.latest_wine_path: Final[Path] = ( + platform_dirs.user_data_path / f"wine/wine-{WINE_VERSION}" + ) + self.wine_binary_path: Final[Path] = self.latest_wine_path / "bin" / "wine" + + self.latest_dxvk_path: Final[Path] = ( + platform_dirs.user_data_path / f"wine/dxvk-{DXVK_VERSION}" + ) + self._dlgDownloader: QtWidgets.QProgressDialog | None = None @property @@ -104,38 +132,24 @@ def wine_setup(self) -> None: # Uncomment line below when using Proton # self.proton_documents_symlinker() # noqa: ERA001 - self.latest_wine_version = WINE_URL.split("/download/")[1].split("/")[0] - latest_wine_path = ( - platform_dirs.user_data_path / f"wine/wine-{self.latest_wine_version}" - ) - - if latest_wine_path.exists(): - self.wine_path = latest_wine_path / "bin/wine" + if self.wine_binary_path.exists(): return self.dlgDownloader.setLabelText("Downloading Wine...") - latest_wine_path_tar = ( - latest_wine_path.parent / f"{latest_wine_path.name}.tar.xz" - ) - if not self._downloader(WINE_URL, latest_wine_path_tar): - return + with TemporaryDirectory() as temp_dir_name: + download_path = Path(temp_dir_name) / "wine.tar.xz" - self.dlgDownloader.reset() - self.dlgDownloader.setLabelText("Extracting Wine...") - self.dlgDownloader.setValue(99) - self._wine_extractor(latest_wine_path_tar) - self.dlgDownloader.setValue(100) + if not self._downloader(WINE_URL, download_path): + return - self.wine_path = ( - platform_dirs.user_data_path / f"wine/wine-{self.latest_wine_version}" - ) / "bin/wine" + self.dlgDownloader.reset() + self.dlgDownloader.setLabelText("Extracting Wine...") + self.dlgDownloader.setValue(99) + self._wine_extractor(download_path) + self.dlgDownloader.setValue(100) def dxvk_setup(self) -> None: - self.latest_dxvk_version = DXVK_URL.split("download/v")[1].split("/")[0] - self.latest_dxvk_path = ( - platform_dirs.user_data_path / f"wine/dxvk-{self.latest_dxvk_version}" - ) if self.latest_dxvk_path.exists(): if not ( self.prefix_path / "drive_c/windows/system32/d3d11.dll" @@ -144,17 +158,17 @@ def dxvk_setup(self) -> None: return self.dlgDownloader.setLabelText("Downloading DXVK...") - latest_dxvk_path_tar = ( - self.latest_dxvk_path.parent / f"{self.latest_dxvk_path.name}.tar.gz" - ) - if self._downloader(DXVK_URL, latest_dxvk_path_tar): - self.dlgDownloader.reset() - self.dlgDownloader.setLabelText("Extracting DXVK...") - self.dlgDownloader.setValue(99) - self._dxvk_extracor(latest_dxvk_path_tar) - self.dlgDownloader.setValue(100) + with TemporaryDirectory() as temp_dir_name: + download_path = Path(temp_dir_name) / "dxvk.tar.gz" + + if self._downloader(DXVK_URL, download_path): + self.dlgDownloader.reset() + self.dlgDownloader.setLabelText("Extracting DXVK...") + self.dlgDownloader.setValue(99) + self._dxvk_extracor(download_path) + self.dlgDownloader.setValue(100) - self._dxvk_injector() + self._dxvk_injector() def _downloader(self, url: str, path: Path) -> bool: """Downloads file from url to path and shows progress with self.handle_download_progress""" @@ -180,83 +194,59 @@ def _handle_download_progress(self, index: int, frame: int, size: int) -> None: percent = 100 * index * frame // size self.dlgDownloader.setValue(percent) - def _wine_extractor(self, path: Path) -> None: - path_no_suffix = path.parent / (path.with_suffix("").with_suffix("")) + def _wine_extractor(self, archive_path: Path) -> None: + with TemporaryDirectory() as temp_dir_name: + temp_dir = Path(temp_dir_name) - # Extracts tar.xz file - with lzma.open(path) as file, tarfile.open(fileobj=file) as tar: - tar.extractall(path_no_suffix, filter="data") + # Extract tar.xz archive. + with lzma.open(archive_path) as file, tarfile.open(fileobj=file) as tar: + tar.extractall(temp_dir, filter="data") - # Moves files from nested directory to main one - source_dir = next(path for path in path_no_suffix.glob("*") if path.is_dir()) - move(source_dir, platform_dirs.user_data_path / "wine") - source_dir = platform_dirs.user_data_path / "wine" / source_dir.name - path_no_suffix.rmdir() - source_dir.rename(source_dir.parent / path_no_suffix.name) + source_dir = next(temp_dir.glob("*/")) + if sys.platform == "darwin": + source_dir = source_dir / "Contents" / "Resources" / "wine" + # Using `shutil.move` instead of `Path.rename`, so that it works across + # filesystems. + move(source_dir, self.latest_wine_path) - # Removes downloaded tar.xz - path.unlink() - - # Removes old wine versions + # Remove old WINE versions. for folder in (platform_dirs.user_data_path / "wine").glob("*/"): - if folder.name.startswith("wine") and not folder.name.endswith( - self.latest_wine_version - ): + if folder.name.startswith("wine") and folder != self.latest_wine_path: rmtree(folder) - def _dxvk_extracor(self, path: Path) -> None: - path_no_suffix = path.parent / (path.with_suffix("").with_suffix("")) + def _dxvk_extracor(self, archive_path: Path) -> None: + with TemporaryDirectory() as temp_dir_name: + temp_dir = Path(temp_dir_name) - # Extracts tar.gz file - with tarfile.open(path, "r:gz") as file: - file.extractall( - path_no_suffix.with_name(f"{path_no_suffix.name}_TEMP"), filter="data" - ) + # Extract the tar.gz archive. + with tarfile.open(archive_path, "r:gz") as file: + file.extractall(temp_dir, filter="data") - # Moves files from nested directory to main one - source_dir = next( - iter(path_no_suffix.with_name(f"{path_no_suffix.name}_TEMP").glob("*/")) - ) - move( - path_no_suffix.with_name(f"{path_no_suffix.name}_TEMP") / source_dir, - platform_dirs.user_data_path / "wine", - ) - path_no_suffix.with_name(f"{path_no_suffix.name}_TEMP").rmdir() + source_dir = next(temp_dir.glob("*/")) + # Using `shutil.move` instead of `Path.rename`, so that it works across + # filesystems. + move(source_dir, self.latest_dxvk_path) - # Removes downloaded tar.gz - path.unlink() - - # Removes old dxvk versions + # Remove old DXVK versions. for folder in (platform_dirs.user_data_path / "wine").glob("*/"): - if str(folder.name).startswith("dxvk") and not str(folder.name).endswith( - self.latest_dxvk_version - ): + if folder.name.startswith("dxvk") and folder != self.latest_dxvk_path: rmtree(folder) def _dxvk_injector(self) -> None: - """Adds dxvk to the wine prefix""" - # Makes directories for dxvk dlls in case wine prefix hasn't been run - # yet - (self.prefix_path / "drive_c/windows/system32").mkdir( - parents=True, exist_ok=True - ) - (self.prefix_path / "drive_c/windows/syswow64").mkdir( - parents=True, exist_ok=True + """Add DXVK to the WINE prefix""" + dlls = ( + ("d3d10core.dll", "d3d11.dll") + if sys.platform == "darwin" + else ("dxgi.dll", "d3d10core.dll", "d3d11.dll", "d3d9.dll") ) + for dll in dlls: + # Remove existing DLLs. + (self.prefix_system32 / dll).unlink(missing_ok=True) + (self.prefix_syswow64 / dll).unlink(missing_ok=True) - dll_list = ["dxgi.dll", "d3d10core.dll", "d3d11.dll", "d3d9.dll"] - - for dll in dll_list: - system32_dll = self.prefix_path / "drive_c/windows/system32" / dll - syswow64_dll = self.prefix_path / "drive_c/windows/syswow64" / dll - - # Removes current dlls - (system32_dll).unlink(missing_ok=True) - (syswow64_dll).unlink(missing_ok=True) - - # Symlinks dxvk dlls in to wine prefix - system32_dll.symlink_to(self.latest_dxvk_path / "x64" / dll) - syswow64_dll.symlink_to(self.latest_dxvk_path / "x32" / dll) + # Symlink DXVK DLLs into the WINE prefix. + (self.prefix_system32 / dll).symlink_to(self.latest_dxvk_path / "x64" / dll) + (self.prefix_syswow64 / dll).symlink_to(self.latest_dxvk_path / "x32" / dll) def proton_documents_symlinker(self) -> None: """ @@ -283,7 +273,10 @@ def proton_documents_symlinker(self) -> None: ) def setup_files(self) -> None: - (platform_dirs.user_data_path / "wine").mkdir(parents=True, exist_ok=True) + self.downloads_path.mkdir(parents=True, exist_ok=True) + self.prefix_system32.mkdir(parents=True, exist_ok=True) + self.prefix_syswow64.mkdir(parents=True, exist_ok=True) + self.prefix_path.mkdir(exist_ok=True, parents=True) self.downloads_path.mkdir(exist_ok=True, parents=True) self.wine_setup() @@ -311,12 +304,13 @@ def edit_qprocess_to_use_wine( process_environment = qprocess.processEnvironment() prefix_path: Path | None + wine_path: Path | None if wine_config.builtin_prefix_enabled: if not wine_management.is_setup: wine_management.setup_files() prefix_path = wine_management.prefix_path - wine_path = wine_management.wine_path + wine_path = wine_management.wine_binary_path # Enables ESYNC if open file limit is high enough path = Path("/proc/sys/fs/file-max") @@ -330,11 +324,24 @@ def edit_qprocess_to_use_wine( # the required kernel patches are installed. process_environment.insert("WINEFSYNC", "1") - # Add dll overrides for DirectX, so DXVK is used instead of wine3d # Disable mscoree and mshtml to avoid downloading wine mono and gecko. - process_environment.insert( - "WINEDLLOVERRIDES", "d3d11=n;dxgi=n;d3d10core=n;d3d9=n;mscoree=d;mshtml=d" + wine_dll_overrides: list[str] = ["mscoree=d", "mshtml=d"] + # Add dll overrides for DirectX, so DXVK is used instead of wine3d. + wine_dll_overrides.extend( + ("d3d11=n", "d3d10core=n") + if sys.platform == "darwin" + else ("d3d11=n", "dxgi=n", "d3d10core=n", "d3d9=n") ) + process_environment.insert("WINEDLLOVERRIDES", ";".join(wine_dll_overrides)) + + if sys.platform == "darwin": + # "wine doesn't handle VK_ERROR_DEVICE_LOST correctly" + # -- + process_environment.insert("MVK_CONFIG_RESUME_LOST_DEVICE", "1") + + # See . LOTRO and DDO + # don't use DirectX12. + process_environment.insert("MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS", "0") else: prefix_path = wine_config.user_prefix_path wine_path = wine_config.user_wine_executable_path @@ -348,6 +355,6 @@ def edit_qprocess_to_use_wine( # Move current program to arguments and replace it with WINE. qprocess.setArguments([qprocess.program(), *qprocess.arguments()]) - qprocess.setProgram(str(wine_path)) + qprocess.setProgram(str(wine_path) if wine_path else "") qprocess.setProcessEnvironment(process_environment) From 5a54a3820e494860fd43f26101b36a72ba740774 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 8 Nov 2025 14:40:03 -0600 Subject: [PATCH 13/97] build: add macOS --- .github/workflows/build.yml | 19 +++++++++++-- AUTHORS.md | 2 +- build/nuitka_compile.py | 3 +++ pyproject.toml | 11 ++++++-- uv.lock | 53 ++++++++++++++++++++++++++++++++++--- 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95fcc1c3..3fa3ddc3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,11 +15,17 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-22.04, windows-latest] + os: [ubuntu-22.04, macos-14, macos-15-intel, windows-latest] include: - os: ubuntu-22.04 artifact_path_name: onelauncher.bin artifact_rename: OneLauncher-Linux.bin + - os: macos-14 + artifact_path_name: onelauncher.zip + artifact_rename: OneLauncher-macOS-ARM64.zip + - os: macos-15-intel + artifact_path_name: onelauncher.zip + artifact_rename: OneLauncher-macOS-x86_64.zip - os: windows-latest artifact_path_name: OneLauncher.msi artifact_rename: OneLauncher-Windows.msi @@ -31,6 +37,9 @@ jobs: - name: Install mingw-w64 on Linux if: runner.os == 'Linux' run: sudo apt-get install mingw-w64 + - name: Install mingw-w64 on macOS + if: runner.os == 'macOS' + run: brew install mingw-w64 - name: Build `run_patch_client` if: runner.os != 'Windows' run: make -C src/run_patch_client @@ -59,9 +68,12 @@ jobs: - run: uv sync --locked --no-dev --group build # Nuitka cache - - name: Install ccache for Nuitka + - name: Install ccache for Nuitka on Linux if: runner.os == 'Linux' run: sudo apt-get install -y ccache + - name: Install ccache for Nuitka on MacOS + if: runner.os == 'macOS' + run: brew install ccache - name: Setup Nuitka env variables shell: bash run: | @@ -86,6 +98,9 @@ jobs: - name: Build run: python -m build + - name: Make app zip on MacOS + if: runner.os == 'macOS' + run: ditto -c -k --keepParent build/out/onelauncher.app build/out/onelauncher.zip - name: Rename artifact run: mv build/out/${{ matrix.artifact_path_name }} build/out/${{ matrix.artifact_rename }} - name: Upload build artifact diff --git a/AUTHORS.md b/AUTHORS.md index 63071d06..0c98a7be 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1,7 +1,7 @@ # Credits Lord of the Rings Online and Dungeons & Dragons Online -Launcher for Linux, Mac OS X, and Windows. +Launcher for Linux, macOS, and Windows. - [OneLauncher](https://github.com/JuneStepp/OneLauncher) (C) 2019-2025 June Stepp \ diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index 7ee702d2..bea1dd8a 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -63,6 +63,9 @@ def main( f"--macos-app-name={__about__.__title__}", f"--macos-app-version={__about__.__version__}", "--macos-app-icon=src/onelauncher/images/OneLauncherIcon.png", + # To not conflict with the `onelauncher` folder. + f"--output-filename={__about__.__title__.lower()}.bin", + "--macos-create-app-bundle" ] ) elif sys.platform == "linux": diff --git a/pyproject.toml b/pyproject.toml index c3d300bc..f3d61579 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,12 +60,19 @@ lint = [ test = [ "pytest>=8.3.2", "pytest-randomly>=3.15.0", - # Used to test mypy plugin + # Used to test mypy plugin. "mypy", "pytest-mock>=3.15.1", "pytest-trio>=0.8.0", ] -build = ["Nuitka>=2.4.8", "marko>=2.1.2"] +build = [ + # See . + # Waiting on version 2.8.5 to make it to PyPi. + "Nuitka>=2.4.8, <=2.7.15", + "marko>=2.1.2", + # For converting the icon image. + "ImageIO>=2.37.2 ; sys_platform == 'darwin'", +] dev = [ { include-group = "lint" }, { include-group = "test" }, diff --git a/uv.lock b/uv.lock index d81b2794..cfeb9110 100644 --- a/uv.lock +++ b/uv.lock @@ -301,6 +301,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "imageio" +version = "2.37.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/606be632e37bf8d05b253e8626c2291d74c691ddc7bcdf7d6aaf33b32f6a/imageio-2.37.2.tar.gz", hash = "sha256:0212ef2727ac9caa5ca4b2c75ae89454312f440a756fcfc8ef1993e718f50f8a", size = 389600, upload-time = "2025-11-04T14:29:39.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/fe/301e0936b79bcab4cacc7548bf2853fc28dced0a578bab1f7ef53c9aa75b/imageio-2.37.2-py3-none-any.whl", hash = "sha256:ad9adfb20335d718c03de457358ed69f141021a333c40a53e57273d8a5bd0b9b", size = 317646, upload-time = "2025-11-04T14:29:37.948Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.0" @@ -494,13 +507,29 @@ wheels = [ [[package]] name = "nuitka" -version = "2.8.4" +version = "2.7.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ordered-set" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/87/f20ffda1b6dc04361fa95390f4d47d974ee194e6e1e7688f13d324f3d89b/Nuitka-2.8.4.tar.gz", hash = "sha256:06b020ef33be97194f888dcfcd4c69c8452ceb61b31c7622e610d5156eb7923d", size = 3885111, upload-time = "2025-10-21T10:28:45.499Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/11/005da9e0826ff333096763597699f5ac9ede8e57e31d94d52300e4bc8f1f/Nuitka-2.7.14.tar.gz", hash = "sha256:88233ed175d6d2abb2e1d5fa3c2e28b2fac604764ddc319c614325ff87c77117", size = 3888306, upload-time = "2025-09-08T09:47:05.979Z" } + +[[package]] +name = "numpy" +version = "2.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, + { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, + { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, +] [[package]] name = "onelauncher" @@ -531,10 +560,12 @@ dependencies = [ [package.dev-dependencies] build = [ + { name = "imageio", marker = "sys_platform == 'darwin'" }, { name = "marko" }, { name = "nuitka" }, ] dev = [ + { name = "imageio", marker = "sys_platform == 'darwin'" }, { name = "marko" }, { name = "mypy" }, { name = "nuitka" }, @@ -586,14 +617,16 @@ requires-dist = [ [package.metadata.requires-dev] build = [ + { name = "imageio", marker = "sys_platform == 'darwin'", specifier = ">=2.37.2" }, { name = "marko", specifier = ">=2.1.2" }, - { name = "nuitka", specifier = ">=2.4.8" }, + { name = "nuitka", specifier = ">=2.4.8,<=2.7.15" }, ] dev = [ + { name = "imageio", marker = "sys_platform == 'darwin'", specifier = ">=2.37.2" }, { name = "marko", specifier = ">=2.1.2" }, { name = "mypy" }, { name = "mypy", specifier = ">=1.18.2" }, - { name = "nuitka", specifier = ">=2.4.8" }, + { name = "nuitka", specifier = ">=2.4.8,<=2.7.15" }, { name = "pyside6-essentials", specifier = ">=6.10.0" }, { name = "pytest", specifier = ">=8.3.2" }, { name = "pytest-mock", specifier = ">=3.15.1" }, @@ -655,6 +688,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, ] +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, +] + [[package]] name = "platformdirs" version = "4.5.0" From 87cc8b8fde2f6a06244cf2c1f21b771c508735cf Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 8 Nov 2025 17:36:20 -0600 Subject: [PATCH 14/97] build: update GitHub Actions Nuitka caching --- .github/workflows/build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3fa3ddc3..286c5766 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,7 +67,7 @@ jobs: activate-environment: true - run: uv sync --locked --no-dev --group build - # Nuitka cache + # Nuitka cache. Based on . - name: Install ccache for Nuitka on Linux if: runner.os == 'Linux' run: sudo apt-get install -y ccache @@ -84,11 +84,11 @@ jobs: uses: actions/cache@v4 with: path: ${{ env.NUITKA_CACHE_DIR }} - key: ${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}-nuitka-${{ github.sha }} + key: nuitka-caching-${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}-nuitka-${{ github.sha }} restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}- - ${{ runner.os }}-${{ runner.arch }}-python- - ${{ runner.os }}-${{ runner.arch }}- + nuitka-caching-${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}- + nuitka-${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}- + nuitka-${{ runner.os }}-${{ runner.arch }}- - name: Setup dotnet for building Windows installer if: runner.os == 'Windows' From 8c39161e796e5ae6f0157a595a3ffd38e333a5bd Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 8 Nov 2025 20:35:49 -0600 Subject: [PATCH 15/97] feat: support MacOS native menu bar --- src/onelauncher/main_window.py | 10 +++++ src/onelauncher/ui/main.ui | 81 +++++++++++++++++++++++++++++++++- src/onelauncher/ui/main_uic.py | 21 +++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index ce16fcac..8e4d5815 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -28,6 +28,7 @@ from __future__ import annotations import logging +import sys from functools import partial from pathlib import Path from typing import cast @@ -167,6 +168,15 @@ def setup_ui(self) -> None: self.setupMousePropagation() + # Basic MacOS native menu bar support. + if sys.platform == "darwin": + global_menu_bar = QtWidgets.QMenuBar(parent=None) + menu = QtWidgets.QMenu() + menu.addActions( + (self.ui.actionAbout, self.ui.actionSettings, self.ui.actionExit) + ) + global_menu_bar.addMenu(menu) + async def run(self) -> None: async with trio.open_nursery() as self.nursery: self.setup_ui() diff --git a/src/onelauncher/ui/main.ui b/src/onelauncher/ui/main.ui index cfc8ff83..863475c5 100644 --- a/src/onelauncher/ui/main.ui +++ b/src/onelauncher/ui/main.ui @@ -437,6 +437,36 @@ Dungeons and Dragons Online + + + About + + + QAction::MenuRole::AboutRole + + + + + Settings + + + Settings + + + QAction::MenuRole::PreferencesRole + + + + + Exit + + + Exit + + + QAction::MenuRole::QuitRole + + @@ -474,5 +504,54 @@ txtStatus - + + + actionAbout + triggered() + btnAbout + click() + + + -1 + -1 + + + 698 + 19 + + + + + actionSettings + triggered() + btnOptions + click() + + + -1 + -1 + + + 22 + 19 + + + + + actionExit + triggered() + btnExit + click() + + + -1 + -1 + + + 766 + 19 + + + + diff --git a/src/onelauncher/ui/main_uic.py b/src/onelauncher/ui/main_uic.py index 3c697e79..e818b3e6 100644 --- a/src/onelauncher/ui/main_uic.py +++ b/src/onelauncher/ui/main_uic.py @@ -35,6 +35,15 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: self.actionLOTRO.setObjectName(u"actionLOTRO") self.actionDDO = QAction(winMain) self.actionDDO.setObjectName(u"actionDDO") + self.actionAbout = QAction(winMain) + self.actionAbout.setObjectName(u"actionAbout") + self.actionAbout.setMenuRole(QAction.MenuRole.AboutRole) + self.actionSettings = QAction(winMain) + self.actionSettings.setObjectName(u"actionSettings") + self.actionSettings.setMenuRole(QAction.MenuRole.PreferencesRole) + self.actionExit = QAction(winMain) + self.actionExit.setObjectName(u"actionExit") + self.actionExit.setMenuRole(QAction.MenuRole.QuitRole) self.centralwidget = QWidget(winMain) self.centralwidget.setObjectName(u"centralwidget") self.verticalLayout_4 = QVBoxLayout(self.centralwidget) @@ -249,6 +258,9 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: QWidget.setTabOrder(self.txtFeed, self.txtStatus) self.retranslateUi(winMain) + self.actionAbout.triggered.connect(self.btnAbout.click) + self.actionSettings.triggered.connect(self.btnOptions.click) + self.actionExit.triggered.connect(self.btnExit.click) QMetaObject.connectSlotsByName(winMain) # setupUi @@ -261,6 +273,15 @@ def retranslateUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: #endif // QT_CONFIG(tooltip) self.actionLOTRO.setText(QCoreApplication.translate("winMain", u"Lord of the Rings Online", None)) self.actionDDO.setText(QCoreApplication.translate("winMain", u"Dungeons and Dragons Online", None)) + self.actionAbout.setText(QCoreApplication.translate("winMain", u"About", None)) + self.actionSettings.setText(QCoreApplication.translate("winMain", u"Settings", None)) +#if QT_CONFIG(tooltip) + self.actionSettings.setToolTip(QCoreApplication.translate("winMain", u"Settings", None)) +#endif // QT_CONFIG(tooltip) + self.actionExit.setText(QCoreApplication.translate("winMain", u"Exit", None)) +#if QT_CONFIG(tooltip) + self.actionExit.setToolTip(QCoreApplication.translate("winMain", u"Exit", None)) +#endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) self.btnOptions.setToolTip(QCoreApplication.translate("winMain", u"Settings", None)) #endif // QT_CONFIG(tooltip) From 132ec1f8d775bced3334e08d44c04dc6034fc1ca Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 9 Nov 2025 09:45:57 -0600 Subject: [PATCH 16/97] feat: add MacOS Crossover to setup wizard search dirs --- src/onelauncher/setup_wizard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index a55b56fc..15d9b448 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -345,6 +345,7 @@ def find_games(self) -> None: home_dir / ".local/share/Steam/steamapps/compatdata/", "*", ), + (home_dir / "Library" / "Application Support" / "Crossover" / "Bottles", "*/"), (home_dir / "games", "*/"), ]: for path in prefix_search_start_dir.glob(glob_pattern): From 764fd75e9c4965bbc57fc27bb8cd9a24f7e4c0a2 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 11 Nov 2025 11:18:25 -0600 Subject: [PATCH 17/97] tests: skip case-sensitive only tests on macOS --- tests/onelauncher/test_utilities.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/onelauncher/test_utilities.py b/tests/onelauncher/test_utilities.py index 1d64118c..10e2119f 100644 --- a/tests/onelauncher/test_utilities.py +++ b/tests/onelauncher/test_utilities.py @@ -1,5 +1,5 @@ import logging -import os +import sys from pathlib import Path import pytest @@ -35,8 +35,8 @@ def test_no_matching_path(self, tmp_path: Path) -> None: assert (CaseInsensitiveAbsolutePath(test_path)) == (test_path) @pytest.mark.skipif( - os.name == "nt", - reason="Windows filesystems are case-insentive already, so there can only be one match.", + sys.platform in ("win32", "darwin"), + reason="Windows and MacOS filesystems are case-insentive already by default, so there can only be one match.", ) def test_multiple_matches( self, tmp_path: Path, caplog: pytest.LogCaptureFixture @@ -59,8 +59,8 @@ def test_multiple_matches( caplog.clear() @pytest.mark.skipif( - os.name == "nt", - reason="Windows filesystems are case-insentive already, so there can only be one match.", + sys.platform in ("win32", "darwin"), + reason="Windows and MacOS filesystems are case-insentive already by default, so there can only be one match.", ) def test_multiple_matches_with_one_exact_match( self, tmp_path: Path, caplog: pytest.LogCaptureFixture @@ -81,8 +81,8 @@ def test_multiple_matches_with_one_exact_match( ] @pytest.mark.skipif( - os.name == "nt", - reason="Extra permisions are needed to make symlinks on Windows", + sys.platform == "win32", + reason="Extra permisions are needed to make symlinks on Windows.", ) def test_symlink(self, tmp_path: Path) -> None: folder = tmp_path / "folder" @@ -95,8 +95,8 @@ def test_symlink(self, tmp_path: Path) -> None: ) @pytest.mark.skipif( - os.name == "nt", - reason="Extra permisions are needed to make symlinks on Windows", + sys.platform == "win32", + reason="Extra permisions are needed to make symlinks on Windows.", ) def test_broken_symlink(self, tmp_path: Path) -> None: folder = tmp_path / "folder" From b8b5bbd4e3e953bd9d8ad4ab952d866e5049be63 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 11 Nov 2025 11:20:25 -0600 Subject: [PATCH 18/97] fix: don't skip CaseInsensitiveAbsolutePath on Windows Just running the full logic of CaseInsensitiveAbsolutePath isn't far from the most optimum way of handling how any given path could potentially be either case sensitive or insensitive on Windows and macOS. --- src/onelauncher/utilities.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/onelauncher/utilities.py b/src/onelauncher/utilities.py index 10068c84..13be7241 100644 --- a/src/onelauncher/utilities.py +++ b/src/onelauncher/utilities.py @@ -68,9 +68,7 @@ def __new__(cls, *pathsegments: StrPath) -> Self: normal_path = Path(*pathsegments) if not normal_path.is_absolute(): raise ValueError("Path is not absolute") - # Windows filesystems are already case-insensitive - if os.name == "nt": - return super().__new__(cls, *pathsegments) + path = cls._get_real_path_from_fully_case_insensitive_path(normal_path) return super().__new__(cls, path) @@ -80,15 +78,15 @@ def _get_real_path_from_fully_case_insensitive_path( ) -> Path: """Return any found path that matches base_path when ignoring case""" parts = list(start_path.parts) - if known_to_exist_base_path is None and not os.path.exists(parts[0]): - # If root doesn't exist, nothing else can be checked - return start_path + if known_to_exist_base_path is None: + if not os.path.exists(parts[0]): + # If root doesn't exist, nothing else can be checked. + return start_path - if known_to_exist_base_path is not None: - start_index = len(known_to_exist_base_path.parts) - else: - # Range starts at 1 to ingore root which has already been checked + # Range starts at 1 to ingore root which has just been checked. start_index = 1 + else: + start_index = len(known_to_exist_base_path.parts) for i in range(start_index, len(parts)): current_path_parts = parts if i == len(parts) - 1 else parts[: i + 1] @@ -96,7 +94,7 @@ def _get_real_path_from_fully_case_insensitive_path( case_insensitive_name=current_path_parts[-1], parent_dir=os.path.sep.join(current_path_parts[:-1]), ) - # No version exists, so the original is just returned + # No version exists, so the original is just returned. if real_path_name is None: return start_path From 26304fa2ccf8b1fbce5412e2cde80a79ae053dd6 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 13 Nov 2025 11:36:06 -0600 Subject: [PATCH 19/97] fix: explicitly don't enable ESYNC and FSYNC on macOS Neither of these should have ever actually activated. This is just making that explicit. --- src/onelauncher/wine_environment.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 9fb4ce94..35d5fb8b 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -312,18 +312,6 @@ def edit_qprocess_to_use_wine( prefix_path = wine_management.prefix_path wine_path = wine_management.wine_binary_path - # Enables ESYNC if open file limit is high enough - path = Path("/proc/sys/fs/file-max") - if path.exists(): - with path.open() as file: - file_data = file.read() - if int(file_data) >= ESYNC_MINIMUM_OPEN_FILE_LMIT: - process_environment.insert("WINEESYNC", "1") - - # Enables FSYNC. It overrides ESYNC and will only be used if - # the required kernel patches are installed. - process_environment.insert("WINEFSYNC", "1") - # Disable mscoree and mshtml to avoid downloading wine mono and gecko. wine_dll_overrides: list[str] = ["mscoree=d", "mshtml=d"] # Add dll overrides for DirectX, so DXVK is used instead of wine3d. @@ -334,6 +322,17 @@ def edit_qprocess_to_use_wine( ) process_environment.insert("WINEDLLOVERRIDES", ";".join(wine_dll_overrides)) + if sys.platform != "darwin": + # Enable ESYNC if open file limit is high enough. + if (path := Path("/proc/sys/fs/file-max")).exists() and int( + path.read_text() + ) >= ESYNC_MINIMUM_OPEN_FILE_LMIT: + process_environment.insert("WINEESYNC", "1") + + # Enable FSYNC. It overrides ESYNC and will only be used if + # the required kernel patches are installed. + process_environment.insert("WINEFSYNC", "1") + if sys.platform == "darwin": # "wine doesn't handle VK_ERROR_DEVICE_LOST correctly" # -- From 41bf5e4b2e3500ab3830590614a893041f046e7f Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 15 Nov 2025 22:11:32 -0600 Subject: [PATCH 20/97] refactor(addon_manager): add `AddonType`, misc --- pyproject.toml | 3 +- src/onelauncher/addon_manager.py | 145 +++++++++++++++++++------------ 2 files changed, 91 insertions(+), 57 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f3d61579..df41c263 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,7 +122,8 @@ ignore = [ "PLR0913", # too-many-arguments "PLR0915", # too-many-statements "PLR0912", # too-many-branches - "S113", # request-without-timeout. httpx has default timeouts + "S113", # request-without-timeout. httpx has default timeouts. + "SIM116", # if-else-block-instead-of-dict-lookup. Messes up typing. ] per-file-ignores."tests/**.py" = ["S101"] # assert flake8-annotations.mypy-init-return = true diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index be42b0f9..55cbb51e 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -92,6 +92,9 @@ def createElementNS(self, namespaceURI: str | None, qualifiedName: str) -> Eleme return super().createElementNS(namespaceURI, qualifiedName) +AddonType = Literal["plugin", "music", "skin"] + + class Addon(NamedTuple): interface_id: str file: str @@ -733,7 +736,11 @@ def actionAddonImportSelected(self) -> None: for file in file_names[0]: self.installAddon(Path(file)) - def installAddon(self, addon_path: Path, interface_id: str = "") -> None: + def installAddon( + self, + addon_path: Path, + interface_id: str | None = None, + ) -> None: # Install .abc files if addon_path.suffix == ".abc": self.installAbcFile(addon_path) @@ -741,7 +748,7 @@ def installAddon(self, addon_path: Path, interface_id: str = "") -> None: elif addon_path.suffix == ".rar": logger.error( f"{__title__} does not support .rar archives, because it" - " is a proprietary format that would require and external " + " is a proprietary format that would require an external " "program to extract" ) return @@ -761,7 +768,11 @@ def installAbcFile(self, addon_path: Path) -> None: self.ui.tableMusicInstalled.clearContents() self.getInstalledMusic() - def installZipAddon(self, addon_path: Path, interface_id: str) -> None: + def installZipAddon( + self, + addon_path: Path, + interface_id: str | None, + ) -> None: with TemporaryDirectory() as tmp_dir_name: tmp_dir = CaseInsensitiveAbsolutePath(tmp_dir_name) @@ -786,10 +797,12 @@ def installZipAddon(self, addon_path: Path, interface_id: str) -> None: is not False ): return + # Skins always have `SkinDefinition.xml` files but there aren't necessarily + # at any given directory level. self.install_skin(tmp_dir, interface_id, addon_path.stem) def install_plugin( - self, tmp_dir: CaseInsensitiveAbsolutePath, interface_id: str + self, tmp_dir: CaseInsensitiveAbsolutePath, interface_id: str | None ) -> None: """Install plugin from temporary directory""" if self.config_manager.get_game_config(self.game_id).game_type == GameType.DDO: @@ -853,7 +866,7 @@ def install_plugin( compendium_file = self.generateCompendiumFile( author_folder, interface_id, - "Plugin", + "plugin", table.objectName(), existing_compendium_file, ) @@ -885,12 +898,13 @@ def install_plugin( self.addInstalledPluginsToDB(plugin_files, compendium_files) - self.handleStartupScriptActivationPrompt(table, interface_id) + if interface_id: + self.handleStartupScriptActivationPrompt(table, interface_id) logger.debug( f"Installed plugin corresponding to {plugin_files} ){compendium_files}" ) - self.installAddonRemoteDependencies(f"{table.objectName()}Installed") + self.installAddonRemoteDependencies(self.ui.tablePluginsInstalled) def get_existing_compendium_file( self, tmp_search_dir: CaseInsensitiveAbsolutePath @@ -913,7 +927,10 @@ def get_existing_compendium_file( return None def install_music( - self, tmp_dir: CaseInsensitiveAbsolutePath, interface_id: str, addon_name: str + self, + tmp_dir: CaseInsensitiveAbsolutePath, + interface_id: str | None, + addon_name: str, ) -> None | Literal[False]: if self.config_manager.get_game_config(self.game_id).game_type == GameType.DDO: logger.error("DDO does not support .abc/music files") @@ -936,7 +953,7 @@ def install_music( self.generateCompendiumFile( root_dir, interface_id, - "Music", + "music", table.objectName(), existing_compendium_file, ) @@ -947,15 +964,19 @@ def install_music( self.getInstalledMusic(folders_list=[root_dir]) - self.handleStartupScriptActivationPrompt(table, interface_id) + if interface_id: + self.handleStartupScriptActivationPrompt(table, interface_id) logger.debug(f"{addon_name} music installed at {root_dir}") - self.installAddonRemoteDependencies(f"{table.objectName()}Installed") + self.installAddonRemoteDependencies(self.ui.tableMusicInstalled) return None def install_skin( - self, tmp_dir: CaseInsensitiveAbsolutePath, interface_id: str, addon_name: str + self, + tmp_dir: CaseInsensitiveAbsolutePath, + interface_id: str | None, + addon_name: str, ) -> None: table = self.ui.tableSkins @@ -969,7 +990,7 @@ def install_skin( self.generateCompendiumFile( root_dir, interface_id, - "Skin", + "skin", table.objectName(), existing_compendium_file, ) @@ -980,18 +1001,19 @@ def install_skin( self.getInstalledSkins(folders_list=[root_dir]) - self.handleStartupScriptActivationPrompt(table, interface_id) + if interface_id: + self.handleStartupScriptActivationPrompt(table, interface_id) logger.debug(f"{addon_name} skin installed at {root_dir}") - self.installAddonRemoteDependencies(f"{table.objectName()}Installed") + self.installAddonRemoteDependencies(table=self.ui.tableSkinsInstalled) - def installAddonRemoteDependencies(self, table_name: str) -> None: + def installAddonRemoteDependencies(self, table: QtWidgets.QTableWidget) -> None: """Installs the dependencies for the last installed addon""" # Get dependencies for last column in db dependencies: str | None = None for item in self.c.execute( - f"SELECT Dependencies FROM {table_name} ORDER BY rowid DESC LIMIT 1" # noqa: S608 + f"SELECT Dependencies FROM {table.objectName()} ORDER BY rowid DESC LIMIT 1" # noqa: S608 ): dependencies = item[0] if dependencies is None: @@ -1001,11 +1023,16 @@ def installAddonRemoteDependencies(self, table_name: str) -> None: if not dependency: continue # 0 is the arbitrary ID for Turbine Utilities. 1064 is the ID - # of OneLauncher's upload of the utilities on LotroInterface + # of OneLauncher's upload of the utilities on LotroInterface. interface_id = "1064" if dependency == "0" else dependency - for item in self.c.execute( # nosec - f"SELECT File, Name FROM {table_name.split('Installed')[0]} WHERE InterfaceID = ? AND InterfaceID NOT IN (SELECT InterfaceID FROM {table_name})", # noqa: S608 + for item in self.c.execute( + ( + "SELECT File, Name FROM " # noqa: S608 + f"{self.getRemoteOrLocalTableFromOne(table, remote=True).objectName()} " + "WHERE InterfaceID = ? AND InterfaceID NOT IN " + f"(SELECT InterfaceID FROM {table.objectName()})" + ), (interface_id,), ): self.installRemoteAddon(item[0], item[1], interface_id) @@ -1102,7 +1129,7 @@ def generateCompendiumFile( self, tmp_addon_root_dir: CaseInsensitiveAbsolutePath, interface_id: str, - addon_type: str, + addon_type: AddonType, table: str, existing_compendium_file: CaseInsensitiveAbsolutePath | None = None, ) -> CaseInsensitiveAbsolutePath: @@ -1118,7 +1145,7 @@ def generateCompendiumFile( while it is still in a temporary directory for propper .plugin file detection. interface_id (str): [description] - addon_type (str): The type of the addon. ("Plugin", "Music", "Skin") + addon_type (AddonType): The type of the addon. table (str): The database table name for the addon type. Used to get remote addon information. existing_compendium_file (Path, optional): An existing compendium file to @@ -1333,12 +1360,12 @@ def setRemoteAddonToUninstalled( self.c.execute( f"UPDATE {remote_table.objectName()} SET Name = ? WHERE InterfaceID == ?", # nosec # noqa: S608 ( - addon[2], - addon[0], + addon.name, + addon.interface_id, ), ) - # Removes indicator that a new version of an installed addon is out if present. + # Remove indicator that a new version of an installed addon is out if present. # This is important, because addons are uninstalled and then reinstalled # during the update process. self.c.execute( @@ -1442,17 +1469,15 @@ def btnAddonsClicked(self) -> None: def getUninstallFunctionFromTable( self, table: QtWidgets.QTableWidget ) -> Callable[[list[Addon], QtWidgets.QTableWidget], None]: - """Gives function to uninstall addon type for table""" - if "Skins" in table.objectName(): - return self.uninstallSkins - elif "Plugins" in table.objectName(): + """Return function to uninstall addon type for table""" + addon_type = self.get_addon_type_from_table(table) + if addon_type == "plugin": return self.uninstallPlugins - elif "Music" in table.objectName(): + elif addon_type == "skin": + return self.uninstallSkins + elif addon_type == "music": return self.uninstallMusic - else: - raise IndexError( - f"{table.objectName()} doesn't correspond to addon type tab" - ) + assert_never() def installRemoteAddons(self) -> None: table = self.getCurrentTable() @@ -1460,7 +1485,7 @@ def installRemoteAddons(self) -> None: addons, details = self.getSelectedAddons(table) if addons and details: for addon in addons: - self.installRemoteAddon(addon[1], addon[2], addon[0]) + self.installRemoteAddon(addon.file, addon.name, addon.interface_id) self.setRemoteAddonToInstalled(addon, table) self.resetRemoteAddonsTables() @@ -1617,10 +1642,10 @@ def uninstallPlugins( plugin_file.unlink(missing_ok=True) Path(plugin.file).unlink(missing_ok=True) - # Remove author folder if there are no other plugins in it + # Remove author folder if there are no other plugins in it. if plugin_folder: author_dir = plugin_folder.parent - if not list(author_dir.glob("*")): + if next(author_dir.iterdir(), None) is None: author_dir.rmdir() logger.debug(f"{plugin} plugin uninstalled") @@ -1962,7 +1987,7 @@ def contextMenuRequested( # If addon is installed if ( - self.context_menu_selected_table.objectName().endswith("Installed") + self.context_menu_selected_table in self.ui_tables_installed or QtCore.Qt.ItemFlag.ItemIsEnabled not in selected_item.flags() ): menu.addAction(self.ui.actionUninstallAddon) @@ -2085,7 +2110,7 @@ def showSelectedOnLotrointerface(self) -> None: def actionInstallAddonSelected(self) -> None: """ - Installs addon selected by context menu. This function + Install addon selected by context menu. This function should only be called while in one of the remote/find more tabs of the UI. """ @@ -2095,7 +2120,7 @@ def actionInstallAddonSelected(self) -> None: if not addon: return - self.installRemoteAddon(addon[1], addon[2], addon[0]) + self.installRemoteAddon(addon.file, addon.name, addon.interface_id) self.setRemoteAddonToInstalled(addon, table) self.resetRemoteAddonsTables() @@ -2109,7 +2134,7 @@ def actionUninstallAddonSelected(self) -> None: return if self.confirmationPrompt( - "Are you sure you want to uninstall this addon?", addon[2] + text="Are you sure you want to uninstall this addon?", details=addon.name ): uninstall_function = self.getUninstallFunctionFromTable(table) @@ -2267,12 +2292,11 @@ def getOutOfDateAddons(self) -> None: self.loadMusicIfNotDone() if game_config.game_type == GameType.LOTRO: - tables = self.TABLE_LIST[:3] + tables = self.ui_tables_installed else: - tables = ("tableSkinsInstalled",) + tables = (self.ui.tableSkinsInstalled,) - for db_table in tables: - table_installed: QtWidgets.QTableWidget = getattr(self.ui, db_table) + for table_installed in tables: table_remote = self.getRemoteOrLocalTableFromOne( table_installed, remote=True ) @@ -2338,12 +2362,11 @@ def updateAll(self) -> None: self.config_manager.get_game_config(self.game_id).game_type == GameType.LOTRO ): - tables = self.TABLE_LIST[:3] + tables = self.ui_tables_installed else: - tables = ("tableSkinsInstalled",) + tables = (self.ui.tableSkinsInstalled,) - for db_table in tables: - table = getattr(self.ui, db_table) + for table in tables: for addon in self.c.execute( f"SELECT InterfaceID, File, Name FROM {table.objectName()} WHERE Version LIKE '(Outdated) %'" # noqa: S608 ): @@ -2374,7 +2397,7 @@ def updateAddon(self, addon: Addon, table: QtWidgets.QTableWidget) -> None: url = entry[0] if url is None: raise ValueError("Addon not found in DB", addon) - self.installRemoteAddon(url, addon[2], addon[0]) + self.installRemoteAddon(url, addon.name, addon.interface_id) self.setRemoteAddonToInstalled(addon, table_remote) def actionUpdateAddonSelected(self) -> None: @@ -2498,18 +2521,28 @@ def getRelativeStartupScriptFromInterfaceID( return addon_data_folder_relative / script return None + def get_addon_type_from_table(self, table: QtWidgets.QTableWidget) -> AddonType: + table_remote = self.getRemoteOrLocalTableFromOne(table, remote=True) + if table_remote is self.ui.tablePlugins: + return "plugin" + elif table_remote is self.ui.tableSkins: + return "skin" + elif table_remote is self.ui.tableMusic: + return "music" + else: + raise ValueError(f"Unhandled table: {table}") + def getAddonTypeDataFolderFromTable( self, table: QtWidgets.QTableWidget ) -> CaseInsensitiveAbsolutePath: - table_name = table.objectName() - if "Plugins" in table_name: + addon_type = self.get_addon_type_from_table(table) + if addon_type == "plugin": return self.data_folder_plugins - elif "Skins" in table_name: + elif addon_type == "skin": return self.data_folder_skins - elif "Music" in table_name: + elif addon_type == "music": return self.data_folder_music - else: - raise ValueError("Addons table not recognized") + assert_never() def handleStartupScriptActivationPrompt( self, table: QtWidgets.QTableWidget, interface_ID: str From 2872fdc8d70386613306336364dd52d3afa68199 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 16 Nov 2025 00:33:14 -0600 Subject: [PATCH 21/97] fix(addon_manager): `getAddonObjectFromRow` with no interface ID Fixes some context menu stuff. --- src/onelauncher/addon_manager.py | 59 ++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 55cbb51e..6e789dff 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -2151,40 +2151,49 @@ def getAddonObjectFromRow( Gives list of information for addon. The information is: [Interface ID, URL/File (depending on if remote = True or False), Name] """ - interface_ID = self.getTableRowInterfaceID(table, row) - if not interface_ID: - return None + file: str | None = None - file = None - if remote: - table_remote = self.getRemoteOrLocalTableFromOne(table, remote=True) - file = self.getAddonUrlFromInterfaceID( - interface_ID, table_remote, download_url=True + if interface_id := self.getTableRowInterfaceID(table, row): + if remote: + table_remote = self.getRemoteOrLocalTableFromOne(table, remote=True) + file = self.getAddonUrlFromInterfaceID( + interface_id, table_remote, download_url=True + ) + else: + table_installed = self.getRemoteOrLocalTableFromOne(table, remote=False) + file = self.getAddonFileFromInterfaceID(interface_id, table_installed) + + if not file: + return None + + return Addon( + interface_id=interface_id, + file=file, + name=table.item(row, self.TABLE_WIDGET_COLUMN_INDEXES["Name"]).text(), # type: ignore [union-attr] ) - else: - table_installed = self.getRemoteOrLocalTableFromOne(table, remote=False) - if table.objectName().endswith("Installed"): - self.reloadSearch(table_installed) + # Not possible without an interface ID. + if remote is True or table not in self.ui_tables_installed: + return None - item: tuple[str] - for item in self.c.execute( - f"SELECT File FROM {table_installed.objectName()} WHERE rowid=?", # noqa: S608 - ( - table_installed.item( # type: ignore [union-attr] - row, self.TABLE_WIDGET_COLUMN_INDEXES["ID"] - ).text(), - ), - ): - file = str(self.data_folder / item[0]) - else: - file = self.getAddonFileFromInterfaceID(interface_ID, table_installed) + self.reloadSearch(table) + + item: tuple[str] + for item in self.c.execute( + f"SELECT File FROM {table.objectName()} WHERE rowid=?", # noqa: S608 + ( + table.item( # type: ignore [union-attr] + row, self.TABLE_WIDGET_COLUMN_INDEXES["ID"] + ).text(), + ), + ): + file = str(self.data_folder / item[0]) if not file: return None return Addon( - interface_id=interface_ID, + interface_id="", file=file, name=table.item(row, self.TABLE_WIDGET_COLUMN_INDEXES["Name"]).text(), # type: ignore [union-attr] ) From 700cd6c4c49a73642f9bb56701e9f0ac84c36696 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 16 Nov 2025 00:45:32 -0600 Subject: [PATCH 22/97] fix(addon_manager): uninstall with addons button from remote table --- src/onelauncher/addon_manager.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 6e789dff..5b2a5760 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -1584,6 +1584,8 @@ def getSelectedAddons( def uninstallPlugins( self, plugins: list[Addon], table: QtWidgets.QTableWidget ) -> None: + table = self.getRemoteOrLocalTableFromOne(table, remote=False) + for plugin in plugins: if plugin[1].endswith(".plugin"): plugin_files = [Path(plugin[1])] @@ -1657,6 +1659,8 @@ def uninstallPlugins( self.getInstalledPlugins() def uninstallSkins(self, skins: list[Addon], table: QtWidgets.QTableWidget) -> None: + table = self.getRemoteOrLocalTableFromOne(table, remote=False) + for skin in skins: if skin[1].endswith(".skincompendium"): skin_path = Path(skin[1]).parent @@ -1682,6 +1686,8 @@ def uninstallSkins(self, skins: list[Addon], table: QtWidgets.QTableWidget) -> N def uninstallMusic( self, music_list: list[Addon], table: QtWidgets.QTableWidget ) -> None: + table = self.getRemoteOrLocalTableFromOne(table, remote=False) + for music in music_list: if music[1].endswith(".musiccompendium"): music_path = Path(music[1]).parent From 7f433f0375ffc19e5a6692c54a85b1fdd4bdc988 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 16 Nov 2025 00:47:59 -0600 Subject: [PATCH 23/97] fix(addon_manager): `getSelectedAddons` with remote table --- src/onelauncher/addon_manager.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 5b2a5760..5cb00df6 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -1573,7 +1573,9 @@ def getSelectedAddons( selected_addons.append( Addon( interface_id=selected_addon[0], - file=str(self.data_folder / selected_addon[1]), + file=selected_addon[1] + if table in self.ui_tables_remote + else str(self.data_folder / selected_addon[1]), name=selected_addon[2], ) ) From efd37a6d8d51a9937188dfa2c8d918f44842d06c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 16 Nov 2025 00:48:33 -0600 Subject: [PATCH 24/97] fix(addon_manager): duplication and DB clear issues from using `isTableEmpty` --- src/onelauncher/addon_manager.py | 42 ++++++-------------------------- 1 file changed, 7 insertions(+), 35 deletions(-) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 5cb00df6..82e5d5ac 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -360,12 +360,12 @@ def __init__( self.tabBarInstalledIndexChanged(self.ui.tabBarInstalled.currentIndex()) def getInstalledSkins(self, folders_list: list[Path] | None = None) -> None: - if self.isTableEmpty(self.ui.tableSkinsInstalled): - folders_list = None - self.data_folder_skins.mkdir(parents=True, exist_ok=True) if not folders_list: + self.c.execute( + f"DELETE FROM {self.ui.tableSkinsInstalled.objectName()}" # nosec # noqa: S608 + ) folders_list = [ path for path in self.data_folder_skins.glob("*") if path.is_dir() ] @@ -387,13 +387,6 @@ def addInstalledSkinsToDB( ) -> None: table = self.ui.tableSkinsInstalled - # Clears rows from db table if needed (This function is called to add - # newly installed skins after initial load as well) - if self.isTableEmpty(table): - self.c.execute( - f"DELETE FROM {table.objectName()}" # nosec # noqa: S608 - ) - for skin in skins_list_compendium: addon_info = self.parseCompendiumFile(skin, "SkinConfig") if addon_info is None: @@ -413,12 +406,10 @@ def addInstalledSkinsToDB( self.reloadSearch(self.ui.tableSkinsInstalled) def getInstalledMusic(self, folders_list: list[Path] | None = None) -> None: - if self.isTableEmpty(self.ui.tableMusicInstalled): - folders_list = None - self.data_folder_music.mkdir(parents=True, exist_ok=True) if not folders_list: + self.c.execute(f"DELETE FROM {self.ui.tableMusicInstalled.objectName()}") # noqa: S608 folders_list = [ path for path in self.data_folder_music.glob("*") if path.is_dir() ] @@ -458,11 +449,6 @@ def addInstalledMusicToDB( ) -> None: table = self.ui.tableMusicInstalled - # Clears rows from db table if needed (This function is called - # to add newly installed music after initial load as well) - if self.isTableEmpty(table): - self.c.execute("DELETE FROM tableMusicInstalled") - for music in music_list_compendium: addon_info = self.parseCompendiumFile(music, "MusicConfig") if addon_info is None: @@ -486,12 +472,10 @@ def addInstalledMusicToDB( def getInstalledPlugins( self, folders_list: list[CaseInsensitiveAbsolutePath] | None = None ) -> None: - if self.isTableEmpty(self.ui.tablePluginsInstalled): - folders_list = None - self.data_folder_plugins.mkdir(parents=True, exist_ok=True) if not folders_list: + self.c.execute(f"DELETE FROM {self.ui.tablePluginsInstalled.objectName()}") # noqa: S608 folders_list = [ path for path in self.data_folder_plugins.glob("*") if path.is_dir() ] @@ -552,11 +536,6 @@ def addInstalledPluginsToDB( ) -> None: table = self.ui.tablePluginsInstalled - # Clears rows from db table if needed (This function is called to - # add newly installed plugins after initial load as well) - if self.isTableEmpty(table): - self.c.execute("DELETE FROM tablePluginsInstalled") - for file in compendium_files + plugin_files: # Sets tag for plugin file xml search and category for unmanaged # plugins @@ -764,9 +743,8 @@ def installAbcFile(self, addon_path: Path) -> None: logger.debug(f"ABC file installed at {addon_path}") # Plain .abc files are installed to base music directory, - # so what is scanned can't be controlled - self.ui.tableMusicInstalled.clearContents() - self.getInstalledMusic() + # so what is scanned can't be controlled. + self.getInstalledMusic(folders_list=None) def installZipAddon( self, @@ -1656,8 +1634,6 @@ def uninstallPlugins( self.setRemoteAddonToUninstalled(plugin, self.ui.tablePlugins) - # Reloads plugins - table.clearContents() self.getInstalledPlugins() def uninstallSkins(self, skins: list[Addon], table: QtWidgets.QTableWidget) -> None: @@ -1681,8 +1657,6 @@ def uninstallSkins(self, skins: list[Addon], table: QtWidgets.QTableWidget) -> N self.setRemoteAddonToUninstalled(skin, self.ui.tableSkins) - # Reloads skins - table.clearContents() self.getInstalledSkins() def uninstallMusic( @@ -1710,8 +1684,6 @@ def uninstallMusic( self.setRemoteAddonToUninstalled(music, self.ui.tableMusic) - # Reloads music - table.clearContents() self.getInstalledMusic() def checkAddonForDependencies( From 5e34d11e0f749844d24ed0532cecfe0d8980ed87 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 16 Nov 2025 12:52:16 -0600 Subject: [PATCH 25/97] fix(addon_manager): use `self.tables_laoded` instead of `self.isTableEmpty` --- src/onelauncher/addon_manager.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 82e5d5ac..c3cf275d 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -328,6 +328,11 @@ def __init__( self.ui.tableSkins, self.ui.tableMusic, ) + self.tables_loaded: set[QtWidgets.QTableWidget] = set() + """ + Tables that have been loaded. Ex: `self.ui.tableSkinsInstalled` will be added + once local skins have been found and displayed. + """ for table in self.ui_tables_installed + self.ui_tables_remote: table.setColumnCount(len(self.TABLE_WIDGET_COLUMNS)) table.setHorizontalHeaderLabels(self.TABLE_WIDGET_COLUMNS) @@ -1319,9 +1324,7 @@ def searchDB(self, table: QtWidgets.QTableWidget, text: str) -> None: ) self.optimizeTableColumnWidths(table) - - def isTableEmpty(self, table: QtWidgets.QTableWidget) -> bool: - return not table.item(0, self.TABLE_WIDGET_COLUMN_INDEXES["Name"]) + self.tables_loaded.add(table) def reloadSearch(self, table: QtWidgets.QTableWidget) -> None: """Re-searches the current search""" @@ -1329,7 +1332,7 @@ def reloadSearch(self, table: QtWidgets.QTableWidget) -> None: def resetRemoteAddonsTables(self) -> None: for table in self.ui_tables_remote: - if not self.isTableEmpty(table): + if table in self.tables_loaded: self.searchDB(table, "") def setRemoteAddonToUninstalled( @@ -1363,10 +1366,10 @@ def setRemoteAddonToInstalled( ), ) - # Adds row to a visible table. First value in list is row name def addRowToTable( self, table: QtWidgets.QTableWidget, rowid: int | str, addon_info: AddonInfo ) -> None: + """Add row to a visible table. First value in list is row name""" table.setSortingEnabled(False) rows = table.rowCount() @@ -1754,15 +1757,15 @@ def tabBarInstalledIndexChanged(self, index: int) -> None: self.searchSearchBarContents() def loadPluginsIfNotDone(self) -> None: - if self.isTableEmpty(self.ui.tablePluginsInstalled): + if self.ui.tablePluginsInstalled not in self.tables_loaded: self.getInstalledPlugins() def loadSkinsIfNotDone(self) -> None: - if self.isTableEmpty(self.ui.tableSkinsInstalled): + if self.ui.tableSkinsInstalled not in self.tables_loaded: self.getInstalledSkins() def loadMusicIfNotDone(self) -> None: - if self.isTableEmpty(self.ui.tableMusicInstalled): + if self.ui.tableMusicInstalled not in self.tables_loaded: self.getInstalledMusic() def tabBarRemoteIndexChanged(self, index: int) -> None: @@ -1792,7 +1795,7 @@ def tabBarIndexChanged(self, index: int) -> None: # Handle the first time this tab is swtiched to. # Populate remote addons tables if not done already. - if self.isTableEmpty(self.ui.tableSkins) and self.loadRemoteAddons(): + if self.ui.tableSkins not in self.tables_loaded and self.loadRemoteAddons(): self.getOutOfDateAddons() # Make sure correct stacked widget page is selected self.tabBarRemoteIndexChanged(self.ui.tabBarRemote.currentIndex()) @@ -2441,7 +2444,7 @@ def loadRemoteDataIfNotDone(self) -> bool: # been found. if not self.loadRemoteAddons(): return False - if self.isTableEmpty(self.ui.tableSkins): + if self.ui.tableSkins not in self.tables_loaded: self.getOutOfDateAddons() return True From b06ac5f399a0bf441eeecf459e84cd92c653ee05 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 16 Nov 2025 14:50:08 -0600 Subject: [PATCH 26/97] fix(addon_manager): updating multiple addons at once --- src/onelauncher/addon_manager.py | 44 ++++++++++---------------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index c3cf275d..33724244 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -1009,7 +1009,7 @@ def installAddonRemoteDependencies(self, table: QtWidgets.QTableWidget) -> None: # of OneLauncher's upload of the utilities on LotroInterface. interface_id = "1064" if dependency == "0" else dependency - for item in self.c.execute( + for item in tuple(self.c.execute( ( "SELECT File, Name FROM " # noqa: S608 f"{self.getRemoteOrLocalTableFromOne(table, remote=True).objectName()} " @@ -1017,7 +1017,7 @@ def installAddonRemoteDependencies(self, table: QtWidgets.QTableWidget) -> None: f"(SELECT InterfaceID FROM {table.objectName()})" ), (interface_id,), - ): + )): self.installRemoteAddon(item[0], item[1], interface_id) def fix_improper_root_dir_addon( @@ -2275,9 +2275,6 @@ def getOutOfDateAddons(self) -> None: in installed table and '(Updated) ' in remote table. These are prepended to the Version column. """ - if not self.loadRemoteDataIfNotDone(): - return - game_config = self.config_manager.get_game_config(self.game_id) if game_config.game_type != GameType.DDO: self.loadSkinsIfNotDone() @@ -2301,10 +2298,10 @@ def getOutOfDateAddons(self) -> None: ) } - for addon in self.c.execute( + for addon in tuple(self.c.execute( f"SELECT Version, InterfaceID, rowid FROM {table_installed.objectName()} WHERE" # noqa: S608 f" InterfaceID != ''" - ): + )): # Will raise KeyError if addon has Interface ID that isn't in # remote table. try: @@ -2347,8 +2344,9 @@ def markAddonForUpdating( ) def updateAll(self) -> None: - if not self.loadRemoteDataIfNotDone(): - return None + if not self.loadRemoteAddons(): + return + self.getOutOfDateAddons() if ( self.config_manager.get_game_config(self.game_id).game_type @@ -2359,8 +2357,10 @@ def updateAll(self) -> None: tables = (self.ui.tableSkinsInstalled,) for table in tables: - for addon in self.c.execute( - f"SELECT InterfaceID, File, Name FROM {table.objectName()} WHERE Version LIKE '(Outdated) %'" # noqa: S608 + for addon in tuple( + self.c.execute( + f"SELECT InterfaceID, File, Name FROM {table.objectName()} WHERE Version LIKE '(Outdated) %'" # noqa: S608 + ) ): self.updateAddon( Addon( @@ -2393,9 +2393,6 @@ def updateAddon(self, addon: Addon, table: QtWidgets.QTableWidget) -> None: self.setRemoteAddonToInstalled(addon, table_remote) def actionUpdateAddonSelected(self) -> None: - if not self.loadRemoteDataIfNotDone(): - return - table = self.context_menu_selected_table row = self.context_menu_selected_row addon = self.getAddonObjectFromRow(table, row, remote=False) @@ -2412,9 +2409,6 @@ def updateSelectedAddons(self) -> None: table = self.getCurrentTable() addons, _ = self.getSelectedAddons(table) - if not self.loadRemoteDataIfNotDone(): - return - if addons: for addon in addons: if self.checkIfAddonHasUpdate(addon, table): @@ -2436,18 +2430,6 @@ def checkIfAddonHasUpdate( ) return None - def loadRemoteDataIfNotDone(self) -> bool: - """ - Loads remote addons and checks if addons have updates if not done yet - """ - # If remote addons haven't been loaded then out of date addons haven't - # been found. - if not self.loadRemoteAddons(): - return False - if self.ui.tableSkins not in self.tables_loaded: - self.getOutOfDateAddons() - return True - def actionEnableStartupScriptSelected(self) -> None: if not self.context_menu_selected_interface_ID: return @@ -2501,10 +2483,10 @@ def getRelativeStartupScriptFromInterfaceID( """Returns path of startup script relative to game documents settings directory""" table_local = self.getRemoteOrLocalTableFromOne(table, remote=False) entry: tuple[str] - for entry in self.c.execute( + for entry in tuple(self.c.execute( f"SELECT StartupScript FROM {table_local.objectName()} WHERE InterfaceID = ?", # noqa: S608 (interface_ID,), - ): + )): if entry[0]: script = entry[0].replace("\\", "/") addon_data_folder_relative = self.getAddonTypeDataFolderFromTable( From acf4b32d3a2c8795ffdcc8c30eec2313d3c3ce5b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 4 Dec 2025 17:22:49 -0600 Subject: [PATCH 27/97] fix: handle empty `queueurls` --- src/onelauncher/main_window.py | 2 +- src/onelauncher/network/world.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 8e4d5815..844e0436 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -646,7 +646,7 @@ async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: ) return - if selected_world_status.queue_url != "": + if selected_world_status.queue_url: await self.world_queue( queueURL=selected_world_status.queue_url, account_number=account_number, diff --git a/src/onelauncher/network/world.py b/src/onelauncher/network/world.py index 9f34bdbb..d6651598 100644 --- a/src/onelauncher/network/world.py +++ b/src/onelauncher/network/world.py @@ -25,8 +25,6 @@ def __init__(self, queue_url: str, login_server: str) -> None: @property def queue_url(self) -> str: - """URL used to queue for world login. - Will be an empty string, if no queueing is needed.""" return self._queue_url @property @@ -73,12 +71,21 @@ async def get_status(self) -> WorldStatus: XMLSchemaValidationError: Status XML doesn't match schema """ status_dict = await self._get_status_dict(self.status_server_url) + + if not status_dict["queueurls"]: + # There have yet to be any modern examples of queue URLs not being + # returned when the world is up, but there has been at least one + # example of it hapening while the world is down. + # See . + raise WorldUnavailableError(f"{self} world unavailable") queue_urls: tuple[str, ...] = tuple( url for url in status_dict["queueurls"].split(";") if url ) + login_servers: tuple[str, ...] = tuple( server for server in status_dict["loginservers"].split(";") if server ) + return WorldStatus(queue_urls[0], login_servers[0]) async def _get_status_dict(self, status_server_url: str) -> dict[str, Any]: From 5122676371cd7e87c9f9b5bb62d575ead5097a5d Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 5 Dec 2025 15:52:38 -0600 Subject: [PATCH 28/97] feat: update Linux WINE --- src/onelauncher/wine_environment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 35d5fb8b..9fd84f70 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -61,8 +61,8 @@ else: # To use Proton, replace link with Proton build and uncomment # `self.proton_documents_symlinker()` in wine_setup in wine_management - WINE_VERSION = "10.19-staging-tkg-amd64-wow64" - WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.19/wine-10.19-staging-tkg-amd64-wow64.tar.xz" + WINE_VERSION = "10.20-staging-tkg-amd64-wow64" + WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.20/wine-10.20-staging-tkg-amd64-wow64.tar.xz" DXVK_VERSION = "2.7.1" DXVK_URL = ( "https://github.com/doitsujin/dxvk/releases/download/v2.7.1/dxvk-2.7.1.tar.gz" From 607f1761cde6fa51e7e04815a359653810eaeea8 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 5 Dec 2025 16:04:43 -0600 Subject: [PATCH 29/97] refactor(wine): reorder methods --- src/onelauncher/wine_environment.py | 130 ++++++++++++++-------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 9fd84f70..fcd15468 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -126,6 +126,30 @@ def create_progress_dialog(self) -> QtWidgets.QProgressDialog: dialog.setCancelButton(None) return dialog + def _downloader(self, url: str, path: Path) -> bool: + """Downloads file from url to path and shows progress with self.handle_download_progress""" + try: + ssl._create_default_https_context = partial( + ssl.create_default_context, cafile=certifi.where() + ) + request.urlretrieve( # noqa: S310 + url, str(path), self._handle_download_progress + ) + return True + except (URLError, HTTPError) as error: + logger.error(error.reason, exc_info=True) + show_warning_message( + f"There was an error downloading '{url}'. " + "You may want to check your network connection.", + get_qapp().activeWindow(), + ) + return False + + def _handle_download_progress(self, index: int, frame: int, size: int) -> None: + """Updates progress bar with download progress""" + percent = 100 * index * frame // size + self.dlgDownloader.setValue(percent) + def wine_setup(self) -> None: """Sets wine program and downloads wine if it is not there or a new version is needed""" @@ -149,50 +173,29 @@ def wine_setup(self) -> None: self._wine_extractor(download_path) self.dlgDownloader.setValue(100) - def dxvk_setup(self) -> None: - if self.latest_dxvk_path.exists(): - if not ( - self.prefix_path / "drive_c/windows/system32/d3d11.dll" - ).is_symlink(): - self._dxvk_injector() - return - - self.dlgDownloader.setLabelText("Downloading DXVK...") - with TemporaryDirectory() as temp_dir_name: - download_path = Path(temp_dir_name) / "dxvk.tar.gz" - - if self._downloader(DXVK_URL, download_path): - self.dlgDownloader.reset() - self.dlgDownloader.setLabelText("Extracting DXVK...") - self.dlgDownloader.setValue(99) - self._dxvk_extracor(download_path) - self.dlgDownloader.setValue(100) + def proton_documents_symlinker(self) -> None: + """ + Symlinks prefix documents folder to system documents folder.path + This is needed for Proton. + """ + prefix_documents_folder = ( + self.prefix_path / "drive_c/users/steamuser/My Documents" + ) - self._dxvk_injector() + # Will assume that the user has set something else up for now if the + # folder already exists + if prefix_documents_folder.exists(): + return - def _downloader(self, url: str, path: Path) -> bool: - """Downloads file from url to path and shows progress with self.handle_download_progress""" - try: - ssl._create_default_https_context = partial( - ssl.create_default_context, cafile=certifi.where() - ) - request.urlretrieve( # noqa: S310 - url, str(path), self._handle_download_progress - ) - return True - except (URLError, HTTPError) as error: - logger.error(error.reason, exc_info=True) - show_warning_message( - f"There was an error downloading '{url}'. " - "You may want to check your network connection.", - get_qapp().activeWindow(), - ) - return False + # Make sure system documents folder and prefix documents root folder + # exists + platform_dirs.user_documents_path.mkdir(exist_ok=True) + prefix_documents_folder.parent.mkdir(exist_ok=True, parents=True) - def _handle_download_progress(self, index: int, frame: int, size: int) -> None: - """Updates progress bar with download progress""" - percent = 100 * index * frame // size - self.dlgDownloader.setValue(percent) + # Make symlink to system documents folder + platform_dirs.user_documents_path.symlink_to( + prefix_documents_folder, target_is_directory=True + ) def _wine_extractor(self, archive_path: Path) -> None: with TemporaryDirectory() as temp_dir_name: @@ -214,6 +217,27 @@ def _wine_extractor(self, archive_path: Path) -> None: if folder.name.startswith("wine") and folder != self.latest_wine_path: rmtree(folder) + def dxvk_setup(self) -> None: + if self.latest_dxvk_path.exists(): + if not ( + self.prefix_path / "drive_c/windows/system32/d3d11.dll" + ).is_symlink(): + self._dxvk_injector() + return + + self.dlgDownloader.setLabelText("Downloading DXVK...") + with TemporaryDirectory() as temp_dir_name: + download_path = Path(temp_dir_name) / "dxvk.tar.gz" + + if self._downloader(DXVK_URL, download_path): + self.dlgDownloader.reset() + self.dlgDownloader.setLabelText("Extracting DXVK...") + self.dlgDownloader.setValue(99) + self._dxvk_extracor(download_path) + self.dlgDownloader.setValue(100) + + self._dxvk_injector() + def _dxvk_extracor(self, archive_path: Path) -> None: with TemporaryDirectory() as temp_dir_name: temp_dir = Path(temp_dir_name) @@ -248,30 +272,6 @@ def _dxvk_injector(self) -> None: (self.prefix_system32 / dll).symlink_to(self.latest_dxvk_path / "x64" / dll) (self.prefix_syswow64 / dll).symlink_to(self.latest_dxvk_path / "x32" / dll) - def proton_documents_symlinker(self) -> None: - """ - Symlinks prefix documents folder to system documents folder.path - This is needed for Proton. - """ - prefix_documents_folder = ( - self.prefix_path / "drive_c/users/steamuser/My Documents" - ) - - # Will assume that the user has set something else up for now if the - # folder already exists - if prefix_documents_folder.exists(): - return - - # Make sure system documents folder and prefix documents root folder - # exists - platform_dirs.user_documents_path.mkdir(exist_ok=True) - prefix_documents_folder.parent.mkdir(exist_ok=True, parents=True) - - # Make symlink to system documents folder - platform_dirs.user_documents_path.symlink_to( - prefix_documents_folder, target_is_directory=True - ) - def setup_files(self) -> None: self.downloads_path.mkdir(parents=True, exist_ok=True) self.prefix_system32.mkdir(parents=True, exist_ok=True) From f6560ae14912c568853aa337ea9c82118fb80445 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 6 Dec 2025 11:48:47 -0600 Subject: [PATCH 30/97] fix(wine): use patched MoltenVK --- src/onelauncher/wine_environment.py | 74 ++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 12 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index fcd15468..87f70d20 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -53,6 +53,8 @@ logger = logging.getLogger(__name__) +MOLTENVK_VERSION = "1.2.10-cxp20241028-UE4hack-zeroinit" +MOLTENVK_URL = "https://github.com/Sikarugir-App/MoltenVK/releases/download/v1.2.10/macos-1.2.10-cxp20241028-UE4hack-zeroinit.tar.xz" if sys.platform == "darwin": WINE_VERSION = "staging-10.20" WINE_URL = "https://github.com/Gcenx/macOS_Wine_builds/releases/download/10.20/wine-staging-10.20-osx64.tar.xz" @@ -90,14 +92,15 @@ def __init__(self) -> None: ) self.downloads_path: Final[Path] = platform_dirs.user_data_path / "wine" - self.latest_wine_path: Final[Path] = ( - platform_dirs.user_data_path / f"wine/wine-{WINE_VERSION}" + self.downloads_path / f"wine-{WINE_VERSION}" ) self.wine_binary_path: Final[Path] = self.latest_wine_path / "bin" / "wine" - self.latest_dxvk_path: Final[Path] = ( - platform_dirs.user_data_path / f"wine/dxvk-{DXVK_VERSION}" + self.downloads_path / f"dxvk-{DXVK_VERSION}" + ) + self.latest_moltenvk_path: Final[Path] = ( + self.downloads_path / f"moltenvk-{MOLTENVK_VERSION}" ) self._dlgDownloader: QtWidgets.QProgressDialog | None = None @@ -213,7 +216,7 @@ def _wine_extractor(self, archive_path: Path) -> None: move(source_dir, self.latest_wine_path) # Remove old WINE versions. - for folder in (platform_dirs.user_data_path / "wine").glob("*/"): + for folder in self.downloads_path.glob("*/"): if folder.name.startswith("wine") and folder != self.latest_wine_path: rmtree(folder) @@ -252,7 +255,7 @@ def _dxvk_extracor(self, archive_path: Path) -> None: move(source_dir, self.latest_dxvk_path) # Remove old DXVK versions. - for folder in (platform_dirs.user_data_path / "wine").glob("*/"): + for folder in self.downloads_path.glob("*/"): if folder.name.startswith("dxvk") and folder != self.latest_dxvk_path: rmtree(folder) @@ -272,16 +275,67 @@ def _dxvk_injector(self) -> None: (self.prefix_system32 / dll).symlink_to(self.latest_dxvk_path / "x64" / dll) (self.prefix_syswow64 / dll).symlink_to(self.latest_dxvk_path / "x32" / dll) + def moltenvk_setup(self) -> None: + if self.latest_moltenvk_path.exists(): + self._moltenvk_injector() + return + + self.dlgDownloader.setLabelText("Downloading MoltenVK...") + + with TemporaryDirectory() as temp_dir_name: + download_path = Path(temp_dir_name) / "moltenvk.tar.xz" + + if not self._downloader(MOLTENVK_URL, download_path): + return + + self.dlgDownloader.reset() + self.dlgDownloader.setLabelText("Extracting MoltenVK...") + self.dlgDownloader.setValue(99) + self._moltenvk_extractor(download_path) + self.dlgDownloader.setValue(100) + + self._moltenvk_injector() + + def _moltenvk_extractor(self, archive_path: Path) -> None: + with TemporaryDirectory() as temp_dir_name: + temp_dir = Path(temp_dir_name) + + # Extract the tar.xz archive. + with lzma.open(archive_path) as file, tarfile.open(fileobj=file) as tar: + tar.extractall(temp_dir, filter="data") + + source_dir = ( + next(temp_dir.glob("*/")) / "Release" / "MoltenVK" / "dylib" / "macOS" + ) + # Using `shutil.move` instead of `Path.rename`, so that it works across + # filesystems. + move(source_dir, self.latest_moltenvk_path) + + # Remove old MoltenVK versions. + for folder in self.downloads_path.glob("*/"): + if ( + folder.name.startswith("moltenvk") + and folder != self.latest_moltenvk_path + ): + rmtree(folder) + + def _moltenvk_injector(self) -> None: + wine_moltenvk = self.latest_wine_path / "lib" / "libMoltenVK.dylib" + wine_moltenvk.unlink(missing_ok=True) + wine_moltenvk.symlink_to(self.latest_moltenvk_path / "libMoltenVK.dylib") + def setup_files(self) -> None: self.downloads_path.mkdir(parents=True, exist_ok=True) self.prefix_system32.mkdir(parents=True, exist_ok=True) self.prefix_syswow64.mkdir(parents=True, exist_ok=True) - self.prefix_path.mkdir(exist_ok=True, parents=True) - self.downloads_path.mkdir(exist_ok=True, parents=True) self.wine_setup() self.dlgDownloader.reset() self.dxvk_setup() + if sys.platform == "darwin": + self.dlgDownloader.reset() + # Must be after WINE setup, b/c it edits WINE files. + self.moltenvk_setup() self.dlgDownloader.close() self.is_setup = True @@ -337,10 +391,6 @@ def edit_qprocess_to_use_wine( # "wine doesn't handle VK_ERROR_DEVICE_LOST correctly" # -- process_environment.insert("MVK_CONFIG_RESUME_LOST_DEVICE", "1") - - # See . LOTRO and DDO - # don't use DirectX12. - process_environment.insert("MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS", "0") else: prefix_path = wine_config.user_prefix_path wine_path = wine_config.user_wine_executable_path From 53c85b88cbb4a4df73c6fae4992a5ab6972466c9 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 9 Dec 2025 11:09:42 -0600 Subject: [PATCH 31/97] fix: don't pre-format log messages --- build/nuitka_compile.py | 2 +- pyproject.toml | 2 + src/onelauncher/addon_manager.py | 77 +++++++++++-------- src/onelauncher/logs.py | 7 +- src/onelauncher/main.py | 4 +- src/onelauncher/main_window.py | 16 ++-- .../network/game_launcher_config.py | 5 +- src/onelauncher/patch_game_window.py | 2 +- src/onelauncher/resources.py | 4 +- src/onelauncher/setup_wizard.py | 9 ++- src/onelauncher/start_game.py | 2 +- src/onelauncher/ui/start_game_window.py | 8 +- src/onelauncher/wine_environment.py | 8 +- tests/onelauncher/test_cli.py | 2 +- 14 files changed, 84 insertions(+), 64 deletions(-) diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index bea1dd8a..1b924101 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -65,7 +65,7 @@ def main( "--macos-app-icon=src/onelauncher/images/OneLauncherIcon.png", # To not conflict with the `onelauncher` folder. f"--output-filename={__about__.__title__.lower()}.bin", - "--macos-create-app-bundle" + "--macos-create-app-bundle", ] ) elif sys.platform == "linux": diff --git a/pyproject.toml b/pyproject.toml index df41c263..cac67f35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,8 @@ select = [ "ERA", # eradicate "PL", # Pylint "PT", # flake8-pytest-style + "LOG", # flake8-logging + "G", # flake8-logging-format ] ignore = [ "PLR0913", # too-many-arguments diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 33724244..849d3122 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -512,7 +512,8 @@ def removeManagedPluginsFromList( doc = defusedxml.minidom.parse(str(compendium_file)) except xml.parsers.expat.ExpatError: logger.warning( - f"`.plugincompendium` file has invalid XML: {compendium_file}", + "`.plugincompendium` file has invalid XML: %s", + compendium_file, exc_info=True, ) continue @@ -532,7 +533,9 @@ def removeManagedPluginsFromList( plugin_files.remove(file) if not descriptor_path.exists(): - logger.error(f"{compendium_file} has misconfigured descriptors") + logger.error( + "%s has misconfigured descriptors", compendium_file + ) def addInstalledPluginsToDB( self, @@ -574,7 +577,7 @@ def parseCompendiumFile(self, file: Path, tag: str) -> AddonInfo | None: try: doc = defusedxml.minidom.parse(str(file)) except xml.parsers.expat.ExpatError: - logger.exception(f"Compendium file has invalid XML: {file}") + logger.exception("Compendium file has invalid XML: %s", file) return None nodes = doc.getElementsByTagName(tag)[0].childNodes for node in nodes: @@ -731,9 +734,10 @@ def installAddon( return elif addon_path.suffix == ".rar": logger.error( - f"{__title__} does not support .rar archives, because it" + "%s does not support .rar archives, because it" " is a proprietary format that would require an external " - "program to extract" + "program to extract", + __title__, ) return elif addon_path.suffix == ".zip": @@ -745,7 +749,7 @@ def installAbcFile(self, addon_path: Path) -> None: return copy(str(addon_path), self.data_folder_music) - logger.debug(f"ABC file installed at {addon_path}") + logger.debug("ABC file installed at %s", addon_path) # Plain .abc files are installed to base music directory, # so what is scanned can't be controlled. @@ -884,7 +888,7 @@ def install_plugin( if interface_id: self.handleStartupScriptActivationPrompt(table, interface_id) logger.debug( - f"Installed plugin corresponding to {plugin_files} ){compendium_files}" + "Installed plugin corresponding to %s %s", plugin_files, compendium_files ) self.installAddonRemoteDependencies(self.ui.tablePluginsInstalled) @@ -950,7 +954,7 @@ def install_music( if interface_id: self.handleStartupScriptActivationPrompt(table, interface_id) - logger.debug(f"{addon_name} music installed at {root_dir}") + logger.debug("%s music installed at %s", addon_name, root_dir) self.installAddonRemoteDependencies(self.ui.tableMusicInstalled) return None @@ -987,7 +991,7 @@ def install_skin( if interface_id: self.handleStartupScriptActivationPrompt(table, interface_id) - logger.debug(f"{addon_name} skin installed at {root_dir}") + logger.debug("%s skin installed at %s", addon_name, root_dir) self.installAddonRemoteDependencies(table=self.ui.tableSkinsInstalled) @@ -1009,15 +1013,17 @@ def installAddonRemoteDependencies(self, table: QtWidgets.QTableWidget) -> None: # of OneLauncher's upload of the utilities on LotroInterface. interface_id = "1064" if dependency == "0" else dependency - for item in tuple(self.c.execute( - ( - "SELECT File, Name FROM " # noqa: S608 - f"{self.getRemoteOrLocalTableFromOne(table, remote=True).objectName()} " - "WHERE InterfaceID = ? AND InterfaceID NOT IN " - f"(SELECT InterfaceID FROM {table.objectName()})" - ), - (interface_id,), - )): + for item in tuple( + self.c.execute( + ( + "SELECT File, Name FROM " # noqa: S608 + f"{self.getRemoteOrLocalTableFromOne(table, remote=True).objectName()} " + "WHERE InterfaceID = ? AND InterfaceID NOT IN " + f"(SELECT InterfaceID FROM {table.objectName()})" + ), + (interface_id,), + ) + ): self.installRemoteAddon(item[0], item[1], interface_id) def fix_improper_root_dir_addon( @@ -1579,7 +1585,8 @@ def uninstallPlugins( doc = defusedxml.minidom.parse(plugin[1]) except xml.parsers.expat.ExpatError: logger.warning( - f"`.plugincompendium` file has invalid XML: {plugin[1]}", + "`.plugincompendium` file has invalid XML: %s", + plugin[1], exc_info=True, ) continue @@ -1609,7 +1616,8 @@ def uninstallPlugins( doc = defusedxml.minidom.parse(str(plugin_file)) except xml.parsers.expat.ExpatError: logger.warning( - f"`.plugin` file has invalid XML: {plugin_file}", + "`.plugin` file has invalid XML: %s", + plugin_file, exc_info=True, ) else: @@ -1633,7 +1641,7 @@ def uninstallPlugins( if next(author_dir.iterdir(), None) is None: author_dir.rmdir() - logger.debug(f"{plugin} plugin uninstalled") + logger.debug("%s plugin uninstalled", plugin) self.setRemoteAddonToUninstalled(plugin, self.ui.tablePlugins) @@ -1656,7 +1664,7 @@ def uninstallSkins(self, skins: list[Addon], table: QtWidgets.QTableWidget) -> N skin_path = Path(skin.file) rmtree(skin_path) - logger.debug(f"{skin} skin uninstalled") + logger.debug("%s skin uninstalled", skin) self.setRemoteAddonToUninstalled(skin, self.ui.tableSkins) @@ -1683,7 +1691,7 @@ def uninstallMusic( else: rmtree(music_path) - logger.debug(f"{music} music uninstalled") + logger.debug("%s music uninstalled", music) self.setRemoteAddonToUninstalled(music, self.ui.tableMusic) @@ -2298,10 +2306,12 @@ def getOutOfDateAddons(self) -> None: ) } - for addon in tuple(self.c.execute( - f"SELECT Version, InterfaceID, rowid FROM {table_installed.objectName()} WHERE" # noqa: S608 - f" InterfaceID != ''" - )): + for addon in tuple( + self.c.execute( + f"SELECT Version, InterfaceID, rowid FROM {table_installed.objectName()} WHERE" # noqa: S608 + f" InterfaceID != ''" + ) + ): # Will raise KeyError if addon has Interface ID that isn't in # remote table. try: @@ -2454,7 +2464,8 @@ def actionEnableStartupScriptSelected(self) -> None: ) else: logger.error( - f"'{full_script_path}' startup script does not exist, so it could not be enabled." + "'%s' startup script does not exist, so it could not be enabled.", + full_script_path, ) def actionDisableStartupScriptSelected(self) -> None: @@ -2483,10 +2494,12 @@ def getRelativeStartupScriptFromInterfaceID( """Returns path of startup script relative to game documents settings directory""" table_local = self.getRemoteOrLocalTableFromOne(table, remote=False) entry: tuple[str] - for entry in tuple(self.c.execute( - f"SELECT StartupScript FROM {table_local.objectName()} WHERE InterfaceID = ?", # noqa: S608 - (interface_ID,), - )): + for entry in tuple( + self.c.execute( + f"SELECT StartupScript FROM {table_local.objectName()} WHERE InterfaceID = ?", # noqa: S608 + (interface_ID,), + ) + ): if entry[0]: script = entry[0].replace("\\", "/") addon_data_folder_relative = self.getAddonTypeDataFolderFromTable( diff --git a/src/onelauncher/logs.py b/src/onelauncher/logs.py index c9fb738c..8c968e0a 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -28,7 +28,7 @@ class LogLevel(IntEnum): def log_basic_info(logger: logging.Logger) -> None: logger.info("Logging started") - logger.info(f"{__title__}: {__version__}") + logger.info("%s: %s", __title__, __version__) logger.info(platform()) @@ -43,9 +43,7 @@ def handle_uncaught_exceptions( # call the default excepthook saved at __excepthook__ sys.__excepthook__(exc_type, exc_value, exc_traceback) return - logger.critical( - "Uncaught exception:", exc_info=(exc_type, exc_value, exc_traceback) - ) + logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) class RedactHomeDirFormatter(logging.Formatter): @@ -80,6 +78,7 @@ def setup_application_logging(log_level_override: LogLevel | None = None) -> Non # Create or get custom logger logger = logging.getLogger() + logging.logThreads = False # This is for the logger globally. Different handlers # attached to it have their own levels. diff --git a/src/onelauncher/main.py b/src/onelauncher/main.py index 6b15088d..175223e8 100644 --- a/src/onelauncher/main.py +++ b/src/onelauncher/main.py @@ -78,9 +78,7 @@ def verify_configs(config_manager: ConfigManager) -> bool: return True -async def start_ui( - config_manager: ConfigManager, game_id: GameConfigID | None -) -> None: +async def start_ui(config_manager: ConfigManager, game_id: GameConfigID | None) -> None: # Run setup wizard. if not config_manager.program_config_path.exists(): logger.info("No program config found. Starting setup wizard.") diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 844e0436..ca60f91d 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -373,7 +373,7 @@ async def btnSwitchGameClicked(self) -> None: game_type=new_game_type, ) if not new_type_game_ids: - logger.error(f"No {new_game_type} games found to switch to") + logger.error("No %s games found to switch to", new_game_type) return self.game_id = new_type_game_ids[0] await self.InitialSetup() @@ -696,7 +696,7 @@ async def world_queue( people_ahead_in_queue = ( world_queue_result.queue_number - world_queue_result.now_serving_number ) - logger.info(f"Position in queue: {people_ahead_in_queue}") + logger.info("Position in queue: %s", people_ahead_in_queue) def set_banner_image(self) -> None: game_config = self.config_manager.get_game_config(self.game_id) @@ -761,11 +761,11 @@ def setup_game(self) -> bool: game_config.game_directory / f"client_local_{locale.game_language_name}.dat" ).exists(): logger.error( - "There is no game language data for " # noqa: S608 - f"{locale.display_name} installed. " - f"You may have to select {locale.display_name}" - " in the standard game launcher and wait for the data to download." + "There is no game language data for %s installed. You may have to " + "select %s in the standard game launcher and wait for the data to download." " The standard game launcher can be opened from the settings menu.", + locale.display_name, + locale.display_name, ) return False @@ -943,7 +943,7 @@ async def check_for_update() -> None: response.raise_for_status() except httpx.HTTPError: logger.exception( - f"Network error while checking for {__about__.__title__} updates" + "Network error while checking for %s updates", __about__.__title__ ) return release_dictionary = response.json() @@ -971,4 +971,4 @@ async def check_for_update() -> None: show_message_box_details_as_markdown(messageBox) messageBox.exec() else: - logger.info(f"{__about__.__title__} is up to date") + logger.info("%s is up to date", __about__.__title__) diff --git a/src/onelauncher/network/game_launcher_config.py b/src/onelauncher/network/game_launcher_config.py index 3d9f229b..4b4f6a7d 100644 --- a/src/onelauncher/network/game_launcher_config.py +++ b/src/onelauncher/network/game_launcher_config.py @@ -226,8 +226,9 @@ def get_client_filename( keys.pop(new_client_type_index) logger.warning( - f"No client_filename for {preferred_client_type} found. " - f"Returning filename for {new_client_type}" + "No client_filename for %s found. Returning filename for %s", + preferred_client_type, + new_client_type, ) return client_filename, new_client_type or preferred_client_type diff --git a/src/onelauncher/patch_game_window.py b/src/onelauncher/patch_game_window.py index 14c384ce..4fc9a87c 100644 --- a/src/onelauncher/patch_game_window.py +++ b/src/onelauncher/patch_game_window.py @@ -91,7 +91,7 @@ def __init__( # Make sure patch_client exists if not patch_client.exists(): - logger.error(f"Patch client {patch_client} not found") + logger.error("Patch client %s not found", patch_client) return self.progress_monitor = PatchingProgressMonitor() diff --git a/src/onelauncher/resources.py b/src/onelauncher/resources.py index cd3c3bb2..b8ad702c 100644 --- a/src/onelauncher/resources.py +++ b/src/onelauncher/resources.py @@ -167,8 +167,8 @@ def get_game_dir_available_locales(game_dir: Path) -> list[OneLauncherLocale]: ) except KeyError: logger.error( - f"{game_language_name} does not match a game language name for" - f" an available locale." + "%s does not match a game language name for an available locale.", + game_language_name, ) return available_game_locales diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 15d9b448..9d71ee2a 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -345,7 +345,14 @@ def find_games(self) -> None: home_dir / ".local/share/Steam/steamapps/compatdata/", "*", ), - (home_dir / "Library" / "Application Support" / "Crossover" / "Bottles", "*/"), + ( + home_dir + / "Library" + / "Application Support" + / "Crossover" + / "Bottles", + "*/", + ), (home_dir / "games", "*/"), ]: for path in prefix_search_start_dir.glob(glob_pattern): diff --git a/src/onelauncher/start_game.py b/src/onelauncher/start_game.py index 232e1bb8..6f21353c 100644 --- a/src/onelauncher/start_game.py +++ b/src/onelauncher/start_game.py @@ -138,7 +138,7 @@ async def get_launch_args( arg.replace(account_number, "******").replace(ticket, "******") for arg in launch_args ) - logger.debug(f"Game launch arguments generated: {redacted_launch_args}") + logger.debug("Game launch arguments generated: %s", redacted_launch_args) return launch_args diff --git a/src/onelauncher/ui/start_game_window.py b/src/onelauncher/ui/start_game_window.py index 5c0b3c80..2e8a3195 100644 --- a/src/onelauncher/ui/start_game_window.py +++ b/src/onelauncher/ui/start_game_window.py @@ -168,7 +168,7 @@ def run_startup_scripts(self) -> None: game_config = self.config_manager.get_game_config(self.game_id) for script in game_config.addons.enabled_startup_scripts: try: - logger.info(f"Running '{script.relative_path}' startup script...") + logger.info("Running '%s' startup script...", script.relative_path) run_startup_script( script=script, game_directory=game_config.game_directory, @@ -179,10 +179,10 @@ def run_startup_scripts(self) -> None: ) except FileNotFoundError: logger.exception( - f"'{script.relative_path}' startup script does not exist" + "'%s' startup script does not exist", script.relative_path ) - except SyntaxError as e: - logger.exception(f"'{script.relative_path}' ran into syntax error: {e}") + except SyntaxError: + logger.exception("'%s' ran into syntax error", script.relative_path) async def start_game(self) -> None: self.config_manager.update_game_config_file( diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 87f70d20..d7bb24d2 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -139,8 +139,8 @@ def _downloader(self, url: str, path: Path) -> bool: url, str(path), self._handle_download_progress ) return True - except (URLError, HTTPError) as error: - logger.error(error.reason, exc_info=True) + except (URLError, HTTPError): + logger.exception("") show_warning_message( f"There was an error downloading '{url}'. " "You may want to check your network connection.", @@ -293,7 +293,7 @@ def moltenvk_setup(self) -> None: self.dlgDownloader.setValue(99) self._moltenvk_extractor(download_path) self.dlgDownloader.setValue(100) - + self._moltenvk_injector() def _moltenvk_extractor(self, archive_path: Path) -> None: @@ -318,7 +318,7 @@ def _moltenvk_extractor(self, archive_path: Path) -> None: and folder != self.latest_moltenvk_path ): rmtree(folder) - + def _moltenvk_injector(self) -> None: wine_moltenvk = self.latest_wine_path / "lib" / "libMoltenVK.dylib" wine_moltenvk.unlink(missing_ok=True) diff --git a/tests/onelauncher/test_cli.py b/tests/onelauncher/test_cli.py index f25c40c8..bb4caf5e 100644 --- a/tests/onelauncher/test_cli.py +++ b/tests/onelauncher/test_cli.py @@ -1,11 +1,11 @@ from pathlib import Path from shutil import rmtree +import cyclopts import pytest from PySide6 import QtWidgets from pytest_mock import MockerFixture -import cyclopts from onelauncher import cli, main from onelauncher.addons.config import AddonsConfigSection from onelauncher.config_manager import ( From d81893edcd876ba8cc0778744d3145d4abd0ddbf Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 12 Dec 2025 19:09:09 -0600 Subject: [PATCH 32/97] refactor: use `trio.run_process` instead of `QProcess` --- src/onelauncher/async_utils.py | 8 + src/onelauncher/main_window.py | 7 +- src/onelauncher/patch_game.py | 235 +++++++++++++++++++ src/onelauncher/patch_game_window.py | 217 ++++++----------- src/onelauncher/patching_progress_monitor.py | 104 -------- src/onelauncher/settings_window.py | 29 ++- src/onelauncher/start_game.py | 105 ++++++--- src/onelauncher/ui/start_game_window.py | 120 ++++------ src/onelauncher/wine_environment.py | 44 ++-- 9 files changed, 467 insertions(+), 402 deletions(-) create mode 100644 src/onelauncher/patch_game.py delete mode 100644 src/onelauncher/patching_progress_monitor.py diff --git a/src/onelauncher/async_utils.py b/src/onelauncher/async_utils.py index 9f54e879..1406ec47 100644 --- a/src/onelauncher/async_utils.py +++ b/src/onelauncher/async_utils.py @@ -6,6 +6,7 @@ import outcome import trio from PySide6 import QtCore, QtWidgets +from trio.abc import ReceiveStream from typing_extensions import override from onelauncher.qtapp import get_qapp @@ -102,3 +103,10 @@ def start_async_gui(entry: Callable[[], Awaitable[None]]) -> int: QtCore.QTimer.singleShot(0, async_helper.launch_guest_run) # qapp.exec() won't return until trio event loop finishes. return qapp.exec() + + +async def for_each_in_stream(pipe: ReceiveStream, func: Callable[[str], None]) -> None: + async for chunk in pipe: + for line in chunk.decode("utf-8").split("\n"): + if stripped_line := line.strip(): + func(stripped_line) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index ca60f91d..77c4417c 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -328,13 +328,12 @@ async def actionPatchSelected(self) -> None: if game_services_info is None: return - winPatch = PatchWindow( + patch_window = PatchWindow( game_id=self.game_id, config_manager=self.config_manager, - launcher_local_config=self.game_launcher_local_config, - urlPatchServer=game_services_info.patch_server, + patch_server_url=game_services_info.patch_server, ) - winPatch.Run() + await patch_window.run() self.resetFocus() async def btnOptionsSelected(self) -> None: diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py new file mode 100644 index 00000000..23b5f848 --- /dev/null +++ b/src/onelauncher/patch_game.py @@ -0,0 +1,235 @@ +import logging +import os +import subprocess +from functools import partial +from pathlib import Path +from types import MappingProxyType +from typing import Literal, TypeAlias, assert_never + +import attrs +import trio + +from onelauncher.async_utils import for_each_in_stream +from onelauncher.config_manager import ConfigManager +from onelauncher.game_config import GameConfigID +from onelauncher.logs import ExternalProcessLogsFilter +from onelauncher.resources import data_dir +from onelauncher.wine_environment import get_wine_process_args + +logger = logging.getLogger(__name__) +logger.addFilter(ExternalProcessLogsFilter()) + +PatchPhase: TypeAlias = Literal["FullPatch", "FilesOnly", "DataOnly"] + +PATCH_CLIENT_RUNNER = data_dir.parent / "run_patch_client" / "run_ptch_client.exe" +""" +Executable used to run `patchclient.dll` and get output from it. This is done with a +separate program, because `patchclient.dll` is 32-bit. `rundll32.exe` can't be used, +because it doesn't expose the stdout of what it runs. +""" + + +@attrs.frozen +class PatchingProgress: + total_iterations: int + current_iterations: int + + +class PatchingProgressMonitor: + def __init__(self) -> None: + self.reset() + + def reset(self) -> None: + self.patching_type = None + + @property + def patching_type(self) -> Literal["file", "data"] | None: + return self._patching_type + + @patching_type.setter + def patching_type(self, patching_type: Literal["file", "data"] | None) -> None: + self._patching_type = patching_type + self.total_iterations: int = 0 + self.current_iterations: int = 0 + self.applying_forward_iterations: bool = False + + def get_patching_progress(self) -> PatchingProgress: + return PatchingProgress( + total_iterations=self.total_iterations, + current_iterations=self.current_iterations, + ) + + def feed_line(self, line: str) -> PatchingProgress: + cleaned_line = line.strip().lower() + + # Beginning of a patching type + if cleaned_line.startswith("checking files"): + self.patching_type = "file" + return self.get_patching_progress() + elif cleaned_line.startswith("checking data"): + self.patching_type = "data" + return self.get_patching_progress() + # Right after a patching type begins. Find out how many iterations there will be. + if cleaned_line.startswith("files to patch:"): + self.total_iterations = int( + cleaned_line.split("files to patch:")[1].strip().split()[0] + ) + elif cleaned_line.startswith("data patches:"): + self.total_iterations = int( + cleaned_line.split("data patches:")[1].strip().split()[0] + ) + # Data patching has two parts. + # "Applying x forward iterations....(continues for x dots)" and the actual file + # downloading which is the originally set `self.total_iterations` + elif ( + self.patching_type == "data" + and cleaned_line.startswith("applying") + and "forward iterations" in cleaned_line + ): + self.applying_forward_iterations = True + self.total_iterations += int( + cleaned_line.split("applying")[1].strip().split("forward iterations")[0] + ) + + if cleaned_line.startswith("downloading"): + self.applying_forward_iterations = False + self.current_iterations += 1 + # During forward iterations, each "." represents one iteration + elif self.applying_forward_iterations and "." in cleaned_line: + self.current_iterations += len(cleaned_line.split(".")) + + return self.get_patching_progress() + + +def get_patchclient_arguments( + phase: PatchPhase, + patch_server_url: str, + game_id: GameConfigID, + config_manager: ConfigManager, +) -> tuple[str, ...]: + """ + Get arguments to be passed to `patchclient.dll`. + """ + game_config = config_manager.get_game_config(game_id=game_id) + + base_arguments = ( + patch_server_url, + "--language", + game_config.locale.game_language_name + if game_config.locale + else config_manager.get_program_config().default_locale.game_language_name, + *(("--highres",) if game_config.high_res_enabled else ()), + ) + + if phase == "FilesOnly": + phase_arg = "--filesonly" + elif phase == "DataOnly": + phase_arg = "--dataonly" + elif phase == "FullPatch": + phase_arg = "" + else: + assert_never(phase) + + return (*base_arguments, phase_arg) + + +async def patch_game( + patch_server_url: str, + progress_monitor: PatchingProgressMonitor, + game_id: GameConfigID, + config_manager: ConfigManager, +) -> None: + game_config = config_manager.get_game_config(game_id=game_id) + + patch_client = game_config.game_directory / game_config.patch_client_filename + if not patch_client.exists(): + logger.error("Patch client %s not found", game_config.patch_client_filename) + return + + command: tuple[str | Path, ...] = ( + PATCH_CLIENT_RUNNER, + patch_client, + ) + environment = MappingProxyType(os.environ) + + if os.name == "nt": + # The directory with TTEPatchClient.dll has to be in the PATH for + # patchclient.dll to find it when OneLauncher is compilled with Nuitka. + environment = MappingProxyType( + environment + | { + "PATH": f"{environment['PATH']};{game_config.game_directory}" + if "PATH" in environment + else f"{game_config.game_directory}" + } + ) + else: + command, environment = get_wine_process_args( + command=command, environment=environment, wine_config=game_config.wine + ) + + # Run file patching twice to avoid problems when patchclient.dll + # self-patches. + patch_phases: tuple[PatchPhase, ...] = ( + "FilesOnly", + "FilesOnly", + "DataOnly", + ) + try: + async with trio.open_nursery() as nursery: + for phase in patch_phases: + progress_monitor.reset() + process: trio.Process = await nursery.start( + partial( + trio.run_process, + ( + *command, + # `run_ptch_client.exe` takes everything that will get + # passed to `patchclient.dll` as a single argument. + " ".join( + get_patchclient_arguments( + phase=phase, + patch_server_url=patch_server_url, + game_id=game_id, + config_manager=config_manager, + ) + ), + ), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + cwd=game_config.game_directory, + ) + ) + if process.stdout is None or process.stderr is None: + raise TypeError("Process pipe is `None`") + + process_logging_adapter = logging.LoggerAdapter(logger) + process_logging_adapter.extra = { + ExternalProcessLogsFilter.EXTERNAL_PROCESS_ID_KEY: process.pid + } + + def process_output_line(line: str) -> None: + process_logging_adapter.debug(line) # noqa: B023 + progress_monitor.feed_line(line) + + nursery.start_soon( + partial(for_each_in_stream, process.stdout, process_output_line) + ) + nursery.start_soon( + partial( + for_each_in_stream, + process.stderr, + process_logging_adapter.warning, + ) + ) + if await process.wait() != 0: + logger.debug( + "Patching process failed with %s exit status", + process.returncode, + ) + logger.error("Patching failed") + return + except* OSError: + logger.exception("Failed to start patching") diff --git a/src/onelauncher/patch_game_window.py b/src/onelauncher/patch_game_window.py index 4fc9a87c..fbd3532e 100644 --- a/src/onelauncher/patch_game_window.py +++ b/src/onelauncher/patch_game_window.py @@ -27,216 +27,133 @@ # along with OneLauncher. If not, see . ########################################################################### import logging -import os -from typing import Literal, TypeAlias, assert_never +import trio from PySide6 import QtCore, QtWidgets +from qtpy import QtGui +from typing_extensions import override from onelauncher.game_config import GameConfigID -from onelauncher.logs import ExternalProcessLogsFilter, ForwardLogsHandler +from onelauncher.logs import ForwardLogsHandler from onelauncher.qtapp import get_qapp -from onelauncher.resources import data_dir from onelauncher.ui_utilities import log_record_to_rich_text from .config_manager import ConfigManager -from .game_launcher_local_config import GameLauncherLocalConfig -from .patching_progress_monitor import PatchingProgressMonitor +from .patch_game import PATCH_CLIENT_RUNNER, PatchingProgressMonitor, patch_game +from .patch_game import logger as patch_game_logger from .ui.patching_window_uic import Ui_patchingDialog -from .wine_environment import edit_qprocess_to_use_wine logger = logging.getLogger(__name__) class PatchWindow(QtWidgets.QDialog): - PatchPhase: TypeAlias = Literal["FullPatch", "FilesOnly", "DataOnly"] - def __init__( self, game_id: GameConfigID, config_manager: ConfigManager, - launcher_local_config: GameLauncherLocalConfig, - urlPatchServer: str, + patch_server_url: str, ): super().__init__( get_qapp().activeWindow(), QtCore.Qt.WindowType.FramelessWindowHint, ) - self.launcher_local_config = launcher_local_config + self.game_id = game_id + self.config_manager = config_manager + self.patch_server_url = patch_server_url + + self.progress_monitor: PatchingProgressMonitor | None = None self.ui = Ui_patchingDialog() self.ui.setupUi(self) self.setWindowTitle("Patching Output") - self.process_logging_adapter = logging.LoggerAdapter(logger) - logger.addFilter(ExternalProcessLogsFilter()) - ui_logging_handler = ForwardLogsHandler( + self.ui_logs_handler = ForwardLogsHandler( new_log_callback=lambda record: self.ui.txtLog.append( log_record_to_rich_text(record) ), level=logging.INFO, ) - logger.addHandler(ui_logging_handler) - - self.ui.progressBar.reset() - self.ui.btnStop.setText("Close") - self.ui.btnStart.setText("Patch") - self.ui.btnStop.clicked.connect(self.btnStopClicked) - self.ui.btnStart.clicked.connect(self.btnStartClicked) + logger.addHandler(self.ui_logs_handler) + patch_game_logger.addHandler(self.ui_logs_handler) - self.aborted = False self.patching_finished = True - game_config = config_manager.get_game_config(game_id=game_id) - patch_client = game_config.game_directory / game_config.patch_client_filename + def setup_ui(self) -> None: + self.finished.connect(self.cleanup) - # Make sure patch_client exists - if not patch_client.exists(): - logger.error("Patch client %s not found", patch_client) - return - - self.progress_monitor = PatchingProgressMonitor() - - self.process = QtCore.QProcess() - self.process.readyReadStandardOutput.connect(self.readOutput) - self.process.readyReadStandardError.connect(self.readErrors) - self.process.finished.connect(self.processFinished) - self.process.setWorkingDirectory(str(game_config.game_directory)) - if os.name == "nt": - # The directory with TTEPatchClient.dll has to be in the PATH for - # patchclient.dll to find it when OneLauncher is compilled with Nuitka. - environment = self.process.processEnvironment() - existing_path_var = environment.value("PATH", "") - environment.insert( - "PATH", - f"{f'{existing_path_var};' if existing_path_var else ''}{game_config.game_directory}", - ) - self.process.setProcessEnvironment(environment) + self.ui.btnStart.setText("Patch") + self.ui.btnStop.clicked.connect(self.btnStopClicked) + self.ui.btnStart.clicked.connect(lambda: self.nursery.start_soon(self.start)) + self.reset_buttons() - patch_client_runner = ( - data_dir.parent / "run_patch_client" / "run_ptch_client.exe" - ) - if not patch_client_runner.exists(): + if not PATCH_CLIENT_RUNNER.exists(): logger.error("Cannot patch. run_ptch_client.exe is missing.") self.ui.btnStart.setEnabled(False) - self.process.setProgram(str(patch_client_runner)) - self.process.setArguments([str(patch_client)]) - if os.name != "nt": - edit_qprocess_to_use_wine( - qprocess=self.process, wine_config=game_config.wine - ) - # Arguments have to be gotten from self.process, because - # they mey have been changed by edit_qprocess_to_use_wine(). - self.base_process_arguments = tuple(self.process.arguments()) - # Arguments to be passed to patchclient.dll - self.patch_client_arguments = ( - urlPatchServer, - "--language", - game_config.locale.game_language_name - if game_config.locale - else config_manager.get_program_config().default_locale.game_language_name, - *(("--highres",) if game_config.high_res_enabled else ()), - ) - # Run file patching twiceto avoid problems when patchclient.dll - # self-patches - self.patch_phases: tuple[PatchWindow.PatchPhase, ...] = ( - "FilesOnly", - "FilesOnly", - "DataOnly", - ) - self.phase_index: int = 0 - if process_arguments := self.get_process_arguments(): - self.process.setArguments(process_arguments) - - def get_process_arguments(self) -> tuple[str, ...] | None: - if self.phase_index > len(self.patch_phases) - 1 or self.phase_index < 0: - # Finished - return None - current_phase = self.patch_phases[self.phase_index] - if current_phase == "FilesOnly": - phase_arg = "--filesonly" - elif current_phase == "DataOnly": - phase_arg = "--dataonly" - elif current_phase == "FullPatch": - phase_arg = "" - else: - assert_never(current_phase) - - return ( - *self.base_process_arguments, - " ".join([*self.patch_client_arguments, phase_arg]), - ) + async def run(self) -> None: + self.setup_ui() + self.open() + async with trio.open_nursery() as self.nursery: + self.nursery.start_soon(self.keep_progress_bar_updated) + # Will be canceled when the winddow is closed. + self.nursery.start_soon(trio.sleep_forever) - def readOutput(self) -> None: - line = self.process.readAllStandardOutput().toStdString() - if line.strip(): - self.process_logging_adapter.debug(line) + def cleanup(self) -> None: + patch_game_logger.removeHandler(self.ui_logs_handler) + self.ui_logs_handler.close() - progress = self.progress_monitor.feed_line(line) - self.ui.progressBar.setMaximum(progress.total_iterations) - self.ui.progressBar.setValue(progress.current_iterations) + self.nursery.cancel_scope.cancel() - def readErrors(self) -> None: - line = self.process.readAllStandardError().toStdString() - if line.strip(): - self.process_logging_adapter.warning(line) + @override + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self.cleanup() + event.accept() - def resetButtons(self) -> None: + def reset_buttons(self) -> None: self.patching_finished = True self.ui.btnStop.setText("Close") self.ui.btnStart.setEnabled(True) - self.progress_monitor.reset() + + self.progress_monitor = None # Make sure it's not showing a busy indicator self.ui.progressBar.setMinimum(1) self.ui.progressBar.setMaximum(1) self.ui.progressBar.reset() - if self.aborted: - logger.info("*** Aborted ***<") - elif self.get_process_arguments() is None: - logger.info("*** Finished ***") - # Let user know that patching is finished if the window isn't currently - # focussed. - self.activateWindow() def btnStopClicked(self) -> None: if self.patching_finished: self.close() else: - self.process.kill() - self.aborted = True - - if self.process.state() != self.process.ProcessState.Running: - self.resetButtons() - - def processFinished( - self, exitCode: int, exitStatus: QtCore.QProcess.ExitStatus - ) -> None: - if self.aborted: - self.resetButtons() - return - - self.phase_index += 1 - # Handle remaining patching phases - new_arguments = self.get_process_arguments() - if new_arguments is None: - # finished - self.resetButtons() - return - self.process.setArguments(new_arguments) - self.process.start() - - def btnStartClicked(self) -> None: - self.aborted = False + self.patching_cancel_scope.cancel() + logger.info("*** Aborted ***") + + async def keep_progress_bar_updated(self) -> None: + # Will be cancled once the patching window is closed. + while True: + if self.progress_monitor: + progress = self.progress_monitor.get_patching_progress() + self.ui.progressBar.setMaximum(progress.total_iterations) + self.ui.progressBar.setValue(progress.current_iterations) + await trio.sleep(0.1) + + async def start(self) -> None: self.patching_finished = False - self.phase_index = 0 self.ui.btnStart.setEnabled(False) self.ui.btnStop.setText("Abort") - self.process.start() - self.process_logging_adapter.extra = { - ExternalProcessLogsFilter.EXTERNAL_PROCESS_ID_KEY: self.process.processId() - } + self.progress_monitor = PatchingProgressMonitor() + logger.info("*** Started ***") + with trio.CancelScope() as self.patching_cancel_scope: + await patch_game( + patch_server_url=self.patch_server_url, + progress_monitor=self.progress_monitor, + game_id=self.game_id, + config_manager=self.config_manager, + ) + logger.info("*** Finished ***") - def Run(self) -> None: - self.exec() + self.reset_buttons() + # Let user know that patching is finished if the window isn't currently + # focussed. + self.activateWindow() diff --git a/src/onelauncher/patching_progress_monitor.py b/src/onelauncher/patching_progress_monitor.py deleted file mode 100644 index 62da8997..00000000 --- a/src/onelauncher/patching_progress_monitor.py +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env python3 -########################################################################### -# Patching progress analyzer for OneLauncher. -# -# Based on PyLotRO -# (C) 2009 AJackson -# -# Based on LotROLinux -# (C) 2007-2008 AJackson -# -# -# (C) 2019-2025 June Stepp -# -# This file is part of OneLauncher -# -# OneLauncher is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# OneLauncher is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with OneLauncher. If not, see . -########################################################################### - -from typing import Literal - -import attrs - - -@attrs.frozen -class PatchingProgress: - total_iterations: int - current_iterations: int - - -class PatchingProgressMonitor: - def __init__(self) -> None: - self.reset() - - def reset(self) -> None: - self.patching_type = None - - @property - def patching_type(self) -> Literal["file", "data"] | None: - return self._patching_type - - @patching_type.setter - def patching_type(self, patching_type: Literal["file", "data"] | None) -> None: - self._patching_type = patching_type - self.total_iterations: int = 0 - self.current_iterations: int = 0 - self.applying_forward_iterations: bool = False - - def get_patching_progress(self) -> PatchingProgress: - return PatchingProgress( - total_iterations=self.total_iterations, - current_iterations=self.current_iterations, - ) - - def feed_line(self, line: str) -> PatchingProgress: - cleaned_line = line.strip().lower() - - # Beginning of a patching type - if cleaned_line.startswith("checking files"): - self.patching_type = "file" - return self.get_patching_progress() - elif cleaned_line.startswith("checking data"): - self.patching_type = "data" - return self.get_patching_progress() - # Right after a patching type begins. Find out how many iterations there will be. - if cleaned_line.startswith("files to patch:"): - self.total_iterations = int( - cleaned_line.split("files to patch:")[1].strip().split()[0] - ) - elif cleaned_line.startswith("data patches:"): - self.total_iterations = int( - cleaned_line.split("data patches:")[1].strip().split()[0] - ) - # Data patching has two parts. - # "Applying x forward iterations....(continues for x dots)" and the actual file - # downloading which is the originally set `self.total_iterations` - elif ( - self.patching_type == "data" - and cleaned_line.startswith("applying") - and "forward iterations" in cleaned_line - ): - self.applying_forward_iterations = True - self.total_iterations += int( - cleaned_line.split("applying")[1].strip().split("forward iterations")[0] - ) - - if cleaned_line.startswith("downloading"): - self.applying_forward_iterations = False - self.current_iterations += 1 - # During forward iterations, each "." represents one iteration - elif self.applying_forward_iterations and "." in cleaned_line: - self.current_iterations += len(cleaned_line.split(".")) - - return self.get_patching_progress() diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 3386c17f..4ce21463 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -25,11 +25,13 @@ # You should have received a copy of the GNU General Public License # along with OneLauncher. If not, see . ########################################################################### +import logging import os import re from contextlib import suppress from enum import StrEnum from pathlib import Path +from types import MappingProxyType import attrs import trio @@ -59,7 +61,9 @@ from .ui.settings_uic import Ui_dlgSettings from .ui_utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath -from .wine_environment import edit_qprocess_to_use_wine +from .wine_environment import get_wine_process_args + +logger = logging.getLogger(__name__) class TabName(StrEnum): @@ -334,19 +338,28 @@ async def indicate_unavailable_client_types(self) -> None: async def run_standard_game_launcher(self, disable_patching: bool = False) -> None: game_config = self.config_manager.get_game_config(self.game_id) launcher_path = await get_standard_game_launcher_path(game_config=game_config) - if launcher_path is None: show_warning_message("No valid launcher executable found", self) return - process = QtCore.QProcess() - process.setProcessEnvironment(QtCore.QProcessEnvironment.systemEnvironment()) - process.setWorkingDirectory(str(game_config.game_directory)) - process.setProgram(str(launcher_path)) + command: tuple[str | Path, ...] = (launcher_path,) + environment = MappingProxyType(os.environ) if disable_patching: - process.setArguments(["-skiprawdownload", "-disablepatch"]) + command = (*command, "-skiprawdownload", "-disablepatch") if os.name != "nt": - edit_qprocess_to_use_wine(qprocess=process, wine_config=game_config.wine) + command, environment = get_wine_process_args( + command=command, environment=environment, wine_config=game_config.wine + ) + + process = QtCore.QProcess() + q_process_environment = QtCore.QProcessEnvironment() + for env_name, env_val in environment.items(): + q_process_environment.insert(env_name, env_val) + process.setProcessEnvironment(q_process_environment) + process.setProgram(str(command[0])) + process.setArguments([str(arg) for arg in command[1:]]) + process.setWorkingDirectory(str(game_config.game_directory)) + logger.info("Starting standard game launcher: %s", launcher_path) process.startDetached() def browse_for_directory( diff --git a/src/onelauncher/start_game.py b/src/onelauncher/start_game.py index 6f21353c..c86ed8c9 100644 --- a/src/onelauncher/start_game.py +++ b/src/onelauncher/start_game.py @@ -1,19 +1,28 @@ import logging import os +import subprocess +from datetime import UTC, datetime +from functools import partial from pathlib import Path +from types import MappingProxyType -from PySide6 import QtCore +import attrs +import trio +from onelauncher.async_utils import for_each_in_stream +from onelauncher.config_manager import ConfigManager from onelauncher.game_launcher_local_config import GameLauncherLocalConfig from onelauncher.game_utilities import get_game_settings_dir +from onelauncher.logs import ExternalProcessLogsFilter -from .game_config import ClientType, GameConfig +from .game_config import ClientType, GameConfig, GameConfigID from .network.game_launcher_config import GameLauncherConfig from .network.world import World from .resources import OneLauncherLocale -from .wine_environment import edit_qprocess_to_use_wine +from .wine_environment import get_wine_process_args logger = logging.getLogger(__name__) +logger.addFilter(ExternalProcessLogsFilter()) class MissingLaunchArgumentError(Exception): @@ -29,7 +38,7 @@ async def get_launch_args( login_server: str, account_number: str, ticket: str, -) -> list[str]: +) -> tuple[str, ...]: """ Return complete client launch arguments based on client_launch_args_template. @@ -139,59 +148,85 @@ async def get_launch_args( for arg in launch_args ) logger.debug("Game launch arguments generated: %s", redacted_launch_args) - return launch_args + return tuple(launch_args) -async def get_qprocess( +async def start_game( + config_manager: ConfigManager, + game_id: GameConfigID, game_launcher_config: GameLauncherConfig, game_launcher_local_config: GameLauncherLocalConfig, - game_config: GameConfig, - default_locale: OneLauncherLocale, world: World, login_server: str, account_number: str, ticket: str, -) -> QtCore.QProcess: - """Return QProcess configured to start game client. - +) -> int: + """ Raises: MissingLaunchArgumentError + OSError: Error encountered starting or communicating with the process """ - client_filename, client_type = game_launcher_config.get_client_filename( - game_config.client_type + # Game was last played right now. + config_manager.update_game_config_file( + game_id=game_id, + config=attrs.evolve( + config_manager.read_game_config_file(game_id), + last_played=datetime.now(UTC), + ), ) - # Fixes binary path for 64-bit client - if client_type == ClientType.WIN64: - client_relative_path = Path("x64") / client_filename - else: - client_relative_path = Path(client_filename) + game_config = config_manager.get_game_config(game_id) launch_args = await get_launch_args( game_launcher_config=game_launcher_config, game_launcher_local_config=game_launcher_local_config, game_config=game_config, - default_locale=default_locale, + default_locale=config_manager.get_program_config().default_locale, world=world, login_server=login_server, account_number=account_number, ticket=ticket, ) + client_filename, client_type = game_launcher_config.get_client_filename( + game_config.client_type + ) + # Fix binary path for 64-bit client. + if client_type == ClientType.WIN64: + client_relative_path = Path("x64") / client_filename + else: + client_relative_path = Path(client_filename) - process = QtCore.QProcess() - process.setProgram(str(client_relative_path)) - process.setArguments(launch_args) - - process_environment = QtCore.QProcessEnvironment.systemEnvironment() - for name, value in game_config.environment.items(): - process_environment.insert(name, value) - process.setProcessEnvironment(process_environment) - + command: tuple[str | Path, ...] = ( + game_config.game_directory / client_relative_path, + *launch_args, + ) + environment = MappingProxyType(os.environ.copy() | game_config.environment) if os.name != "nt": - edit_qprocess_to_use_wine(qprocess=process, wine_config=game_config.wine) - - process.setWorkingDirectory(str(game_config.game_directory)) - # Just setting the QProcess working directory isn't enough on Windows - if os.name == "nt": - os.chdir(process.workingDirectory()) + command, environment = get_wine_process_args( + command=command, environment=environment, wine_config=game_config.wine + ) - return process + async with trio.open_nursery() as nursery: + process: trio.Process = await nursery.start( + partial( + trio.run_process, + command, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=environment, + cwd=game_config.game_directory, + ) + ) + process_logging_adapter = logging.LoggerAdapter(logger) + process_logging_adapter.extra = { + ExternalProcessLogsFilter.EXTERNAL_PROCESS_ID_KEY: process.pid + } + if process.stdout is None or process.stderr is None: + raise TypeError("Process pipe is `None`") + nursery.start_soon( + partial(for_each_in_stream, process.stdout, process_logging_adapter.debug) + ) + nursery.start_soon( + partial(for_each_in_stream, process.stderr, process_logging_adapter.warning) + ) + return await process.wait() diff --git a/src/onelauncher/ui/start_game_window.py b/src/onelauncher/ui/start_game_window.py index 2e8a3195..fef3b7f7 100644 --- a/src/onelauncher/ui/start_game_window.py +++ b/src/onelauncher/ui/start_game_window.py @@ -26,13 +26,12 @@ # along with OneLauncher. If not, see . ########################################################################### import logging -from datetime import UTC, datetime -import attrs +import trio from PySide6 import QtCore, QtWidgets from onelauncher.game_config import GameConfigID -from onelauncher.logs import ExternalProcessLogsFilter, ForwardLogsHandler +from onelauncher.logs import ForwardLogsHandler from onelauncher.qtapp import get_qapp from onelauncher.ui_utilities import log_record_to_rich_text @@ -43,7 +42,8 @@ from ..game_utilities import get_game_settings_dir from ..network.game_launcher_config import GameLauncherConfig from ..network.world import World -from ..start_game import MissingLaunchArgumentError, get_qprocess +from ..start_game import MissingLaunchArgumentError, start_game +from ..start_game import logger as start_game_logger from .start_game_uic import Ui_startGameDialog logger = logging.getLogger(__name__) @@ -78,16 +78,14 @@ def __init__( self.ui = Ui_startGameDialog() self.ui.setupUi(self) - self.process_logging_adapter = logging.LoggerAdapter(logger) - logger.addFilter(ExternalProcessLogsFilter()) - logger.addHandler( - ForwardLogsHandler( - new_log_callback=lambda record: self.ui.txtLog.append( - log_record_to_rich_text(record) - ), - level=logging.INFO, - ) + self.ui_logs_handler = ForwardLogsHandler( + new_log_callback=lambda record: self.ui.txtLog.append( + log_record_to_rich_text(record) + ), + level=logging.INFO, ) + logger.addHandler(self.ui_logs_handler) + start_game_logger.addHandler(self.ui_logs_handler) # Can't quit until program finishes or is aborted self.ui.btnQuit.setEnabled(False) @@ -95,55 +93,10 @@ def __init__( self.ui.btnAbort.clicked.connect(self.btnAbortClicked) self.ui.btnQuit.clicked.connect(self.btnQuitClicked) - self.aborted = False self.game_finished = False self.show() - async def get_qprocess(self) -> QtCore.QProcess | None: - """Return setup qprocess with connected signals""" - try: - process = await get_qprocess( - game_launcher_config=self.game_launcher_config, - game_launcher_local_config=self.game_launcher_local_config, - game_config=self.config_manager.get_game_config(self.game_id), - default_locale=self.config_manager.get_program_config().default_locale, - world=self.world, - login_server=self.login_server, - account_number=self.account_number, - ticket=self.ticket, - ) - except MissingLaunchArgumentError: - logger.exception( - "Game launch argument missing. Please report this error if using a supported server." - ) - self.reset_buttons() - return None - - def readOutput() -> None: - self.process_logging_adapter.debug( - process.readAllStandardOutput().toStdString() - ) - - def readErrors() -> None: - self.process_logging_adapter.warning( - process.readAllStandardError().toStdString() - ) - - process.readyReadStandardOutput.connect(readOutput) - process.readyReadStandardError.connect(readErrors) - process.finished.connect(self.process_finished) - return process - - def process_finished( - self, exit_code: int, exit_status: QtCore.QProcess.ExitStatus - ) -> None: - self.reset_buttons() - if self.aborted: - logger.info("*** Aborted ***") - else: - logger.info("*** Finished ***") - def reset_buttons(self) -> None: self.game_finished = True self.ui.btnAbort.setText("Close") @@ -151,11 +104,12 @@ def reset_buttons(self) -> None: def btnAbortClicked(self) -> None: if self.game_finished: + start_game_logger.removeHandler(self.ui_logs_handler) + self.ui_logs_handler.close() self.close() else: - self.aborted = True - if self.process: - self.process.kill() + logger.info("*** Aborted ***") + self.game_cancel_scope.cancel() def btnQuitClicked(self) -> None: if self.game_finished: @@ -164,7 +118,7 @@ def btnQuitClicked(self) -> None: app_cancel_scope.cancel() def run_startup_scripts(self) -> None: - """Runs Python scripts from addons with one that is approved by user""" + """Run enabled startup scripts""" game_config = self.config_manager.get_game_config(self.game_id) for script in game_config.addons.enabled_startup_scripts: try: @@ -185,25 +139,33 @@ def run_startup_scripts(self) -> None: logger.exception("'%s' ran into syntax error", script.relative_path) async def start_game(self) -> None: - self.config_manager.update_game_config_file( - game_id=self.game_id, - config=attrs.evolve( - self.config_manager.read_game_config_file(self.game_id), - last_played=datetime.now(UTC), - ), - ) - - self.process = await self.get_qprocess() - if self.process is None: - return - self.game_finished = False self.ui.btnAbort.setText("Abort") self.run_startup_scripts() - self.process.start() - self.process_logging_adapter.extra = { - ExternalProcessLogsFilter.EXTERNAL_PROCESS_ID_KEY: self.process.processId() - } + + self.show() logger.info("*** Started ***") + with trio.CancelScope() as self.game_cancel_scope: + try: + return_code = await start_game( + config_manager=self.config_manager, + game_id=self.game_id, + game_launcher_config=self.game_launcher_config, + game_launcher_local_config=self.game_launcher_local_config, + world=self.world, + login_server=self.login_server, + account_number=self.account_number, + ticket=self.ticket, + ) + if return_code != 0: + logger.error("Game closed unexpectedly") + else: + logger.info("*** Finished ***") + except* MissingLaunchArgumentError: + logger.exception( + "Game launch argument missing. Please report this error if using a supported server." + ) + except* OSError: + logger.exception("Failed to start game") - self.exec() + self.reset_buttons() diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index d7bb24d2..538b3742 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -36,6 +36,7 @@ from pathlib import Path from shutil import move, rmtree from tempfile import TemporaryDirectory +from types import MappingProxyType from typing import Final from urllib import request from urllib.error import HTTPError, URLError @@ -346,16 +347,17 @@ def setup_files(self) -> None: ESYNC_MINIMUM_OPEN_FILE_LMIT = 524288 -def edit_qprocess_to_use_wine( - qprocess: QtCore.QProcess, wine_config: WineConfigSection -) -> None: - """Reconfigures QProcess to use WINE. The program and arguments must be pre-set!""" +def get_wine_process_args( + command: tuple[str | Path, ...], + environment: MappingProxyType[str, str], + wine_config: WineConfigSection, +) -> tuple[tuple[str | Path, ...], MappingProxyType[str, str]]: + """Configure `run_process` arguments to use WINE.""" if os.name == "nt": - logger.warning( - "Attempt to edit QProcess to use WINE on Windows. No changes were made." - ) - return - process_environment = qprocess.processEnvironment() + logger.warning("Attempt to use WINE on Windows. No changes were made.") + return command, environment + + edited_environment = environment.copy() prefix_path: Path | None wine_path: Path | None @@ -374,36 +376,34 @@ def edit_qprocess_to_use_wine( if sys.platform == "darwin" else ("d3d11=n", "dxgi=n", "d3d10core=n", "d3d9=n") ) - process_environment.insert("WINEDLLOVERRIDES", ";".join(wine_dll_overrides)) + edited_environment["WINEDLLOVERRIDES"] = ";".join(wine_dll_overrides) if sys.platform != "darwin": # Enable ESYNC if open file limit is high enough. if (path := Path("/proc/sys/fs/file-max")).exists() and int( path.read_text() ) >= ESYNC_MINIMUM_OPEN_FILE_LMIT: - process_environment.insert("WINEESYNC", "1") + edited_environment["WINEESYNC"] = "1" # Enable FSYNC. It overrides ESYNC and will only be used if # the required kernel patches are installed. - process_environment.insert("WINEFSYNC", "1") + edited_environment["WINEFSYNC"] = "1" if sys.platform == "darwin": # "wine doesn't handle VK_ERROR_DEVICE_LOST correctly" # -- - process_environment.insert("MVK_CONFIG_RESUME_LOST_DEVICE", "1") + edited_environment["MVK_CONFIG_RESUME_LOST_DEVICE"] = "1" else: prefix_path = wine_config.user_prefix_path wine_path = wine_config.user_wine_executable_path if prefix_path: - process_environment.insert("WINEPREFIX", str(prefix_path)) + edited_environment["WINEPREFIX"] = str(prefix_path) - process_environment.insert("WINEDEBUG", wine_config.debug_level or "-all") - if not process_environment.contains("DXVK_LOG_LEVEL"): - process_environment.insert("DXVK_LOG_LEVEL", "error") + edited_environment["WINEDEBUG"] = wine_config.debug_level or "-all" + if "DXVK_LOG_LEVEL" not in edited_environment: + edited_environment["DXVK_LOG_LEVEL"] = "error" - # Move current program to arguments and replace it with WINE. - qprocess.setArguments([qprocess.program(), *qprocess.arguments()]) - qprocess.setProgram(str(wine_path) if wine_path else "") - - qprocess.setProcessEnvironment(process_environment) + return (wine_path if wine_path else "", *command), MappingProxyType( + edited_environment + ) From c6c9e6833164d2ff8d1593d2269cea454a51656a Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 12 Dec 2025 19:18:19 -0600 Subject: [PATCH 33/97] tests: don't log to file during testing --- src/onelauncher/logs.py | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/onelauncher/logs.py b/src/onelauncher/logs.py index 8c968e0a..61ed54d2 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -1,4 +1,5 @@ import logging +import os import sys from collections.abc import Callable from enum import IntEnum @@ -73,10 +74,9 @@ def setup_application_logging(log_level_override: LogLevel | None = None) -> Non file_logging_level = LogLevel.INFO stream_logging_level = LogLevel.WARNING - # Make sure logs dir exists LOGS_DIR.mkdir(exist_ok=True, parents=True) - # Create or get custom logger + # Create or get custom logger. logger = logging.getLogger() logging.logThreads = False @@ -84,31 +84,28 @@ def setup_application_logging(log_level_override: LogLevel | None = None) -> Non # attached to it have their own levels. logger.setLevel(LogLevel.DEBUG) - # Create handlers stream_handler = logging.StreamHandler() stream_handler.setLevel(stream_logging_level) - - log_file = LOGS_DIR / MAIN_LOG_FILE_NAME - file_handler = RotatingFileHandler( - filename=log_file, - mode="a", - maxBytes=10 * 1024 * 1024, - backupCount=2, - encoding=None, - ) - file_handler.setLevel(file_logging_level) - - # Create formatters and add it to handlers stream_format = logging.Formatter("%(name)s - %(levelname)s - %(message)s") stream_handler.setFormatter(stream_format) - file_format = RedactHomeDirFormatter( - "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(lineno)d - %(message)s" - ) - file_handler.setFormatter(file_format) - - # Add handlers to the logger logger.addHandler(stream_handler) - logger.addHandler(file_handler) + + # Don't log to file during testing. + if os.environ.get("PYTEST_VERSION") is None: + log_file = LOGS_DIR / MAIN_LOG_FILE_NAME + file_handler = RotatingFileHandler( + filename=log_file, + mode="a", + maxBytes=10 * 1024 * 1024, + backupCount=2, + encoding=None, + ) + file_handler.setLevel(file_logging_level) + file_format = RedactHomeDirFormatter( + "%(asctime)s - %(process)d - %(name)s - %(levelname)s - %(lineno)d - %(message)s" + ) + file_handler.setFormatter(file_format) + logger.addHandler(file_handler) # Setup handling of uncaught exceptions sys.excepthook = partial(handle_uncaught_exceptions, logger=logger) From 119711ddd279d9754ade8cff51bd6bcac977efb1 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 12 Dec 2025 19:19:50 -0600 Subject: [PATCH 34/97] fix: still check for updates on dev releases I like the idea of not doing that, but sometimes users test dev releases; I wouldn't want them to miss out on new releases. --- src/onelauncher/main_window.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 77c4417c..34df2a40 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -917,9 +917,6 @@ def ClearNews(self) -> None: async def check_for_update() -> None: """Notifies user if their copy of OneLauncher is out of date""" - # Don't unecessarily check for updates during development - if __about__.version_parsed.is_devrelease: - return repository_url = __about__.__project_url__ if not repository_url: logger.warning("No updates URL available") From ecbd1ddcdb8abc0c178fa02e3b39657be352f949 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 12 Dec 2025 19:20:58 -0600 Subject: [PATCH 35/97] style: move function comment to doc string --- src/onelauncher/addon_manager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 849d3122..7296fd39 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -1921,9 +1921,11 @@ def getRemoteAddons( return True - # Downloads file from url to path and shows progress with - # self.handleDownloadProgress def downloader(self, url: str, path: Path) -> bool: + """ + Download file from `url` to `path` and show progress with + `self.handleDownloadProgress`. + """ if url.lower().startswith("http"): try: self.ui.progressBar.setValue(0) From 5def265242ab13a4651679b6fb06545f65094220 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 12 Dec 2025 20:53:44 -0600 Subject: [PATCH 36/97] feat: get rid of start game window Do it all inside the main window --- src/onelauncher/main_window.py | 109 +++++++++++---- src/onelauncher/ui/main.ui | 4 +- src/onelauncher/ui/main_uic.py | 18 +-- src/onelauncher/ui/start_game.ui | 66 --------- src/onelauncher/ui/start_game_uic.py | 68 ---------- src/onelauncher/ui/start_game_window.py | 171 ------------------------ 6 files changed, 95 insertions(+), 341 deletions(-) delete mode 100644 src/onelauncher/ui/start_game.ui delete mode 100644 src/onelauncher/ui/start_game_uic.py delete mode 100644 src/onelauncher/ui/start_game_window.py diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 34df2a40..05a1a1ca 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -42,8 +42,10 @@ from typing_extensions import override from xmlschema import XMLSchemaValidationError +from onelauncher.addons.startup_script import run_startup_script from onelauncher.logs import ForwardLogsHandler from onelauncher.qtapp import get_app_style, get_qapp +from onelauncher.start_game import MissingLaunchArgumentError, start_game from onelauncher.ui.custom_widgets import FramelessQMainWindowWithStylePreview from . import __about__, addon_manager @@ -58,6 +60,7 @@ from .game_utilities import ( InvalidGameDirError, find_game_dir_game_type, + get_game_settings_dir, ) from .network import login_account from .network.game_launcher_config import ( @@ -80,7 +83,6 @@ from .ui.about_uic import Ui_dlgAbout from .ui.main_uic import Ui_winMain from .ui.select_subscription_uic import Ui_dlgSelectSubscription -from .ui.start_game_window import StartGame from .ui_utilities import log_record_to_rich_text, show_message_box_details_as_markdown logger = logging.getLogger(__name__) @@ -99,6 +101,7 @@ def __init__( self.game_id: GameConfigID = game_id self.network_setup_nursery: trio.Nursery | None = None + self.game_cancel_scope: trio.CancelScope | None = None self.addon_manager_window: addon_manager.AddonManagerWindow | None = None self.game_launcher_config: GameLauncherConfig | None = None @@ -154,7 +157,7 @@ def setup_ui(self) -> None: color_scheme_changed.connect( lambda: self.ui.btnAddonManager.setIcon(get_addons_manager_icon()) ) - self.setupBtnLoginMenu() + self.setup_start_game_button() self.ui.btnSwitchGame.clicked.connect( lambda: self.nursery.start_soon(self.btnSwitchGameClicked) ) @@ -207,7 +210,7 @@ def setupMousePropagation(self) -> None: mouse_ignore_list = [ self.ui.btnAbout, self.ui.btnExit, - self.ui.btnLogin, + self.ui.btnStartGame, self.ui.btnMinimize, self.ui.btnOptions, self.ui.btnAddonManager, @@ -230,23 +233,29 @@ def changeEvent(self, event: QtCore.QEvent) -> None: if event.type() == QtCore.QEvent.Type.ThemeChange: get_app_style().update_base_font() - def setupBtnLoginMenu(self) -> None: - """Sets up signals and context menu for btnLoginMenu""" - self.ui.btnLogin.clicked.connect( - lambda: self.nursery.start_soon(self.btnLoginClicked) + def reset_start_game_button(self) -> None: + self.ui.btnStartGame.setText("Play") + self.ui.btnStartGame.setToolTip("Start your adventure!") + + def setup_start_game_button(self) -> None: + """Set up signals and context menu for `btnStartGame`""" + self.reset_start_game_button() + + self.ui.btnStartGame.clicked.connect( + lambda: self.nursery.start_soon(self.start_game_button_clicked) ) # Pressing enter in password box acts like pressing login button self.ui.txtPassword.returnPressed.connect( - lambda: self.nursery.start_soon(self.btnLoginClicked) + lambda: self.nursery.start_soon(self.start_game_button_clicked) ) # Setup context menu - self.btnLoginMenu = QtWidgets.QMenu() - self.btnLoginMenu.addAction(self.ui.actionPatch) + self.btnStartGameMenu = QtWidgets.QMenu() + self.btnStartGameMenu.addAction(self.ui.actionPatch) self.ui.actionPatch.triggered.connect( lambda: self.nursery.start_soon(self.actionPatchSelected) ) - self.ui.btnLogin.setMenu(self.btnLoginMenu) + self.ui.btnStartGame.setMenu(self.btnStartGameMenu) def setup_switch_game_button(self) -> None: """Set icon and dropdown options of switch game button according to current game""" @@ -382,7 +391,33 @@ async def game_switch_action_triggered(self, action: QtGui.QAction) -> None: self.game_id = new_game_id await self.InitialSetup() - async def btnLoginClicked(self) -> None: + def run_startup_scripts(self) -> None: + """Run enabled startup scripts""" + game_config = self.config_manager.get_game_config(self.game_id) + for script in game_config.addons.enabled_startup_scripts: + try: + logger.info("Running '%s' startup script...", script.relative_path) + run_startup_script( + script=script, + game_directory=game_config.game_directory, + documents_config_dir=get_game_settings_dir( + game_config=game_config, + launcher_local_config=self.game_launcher_local_config, + ), + ) + except FileNotFoundError: + logger.exception( + "'%s' startup script does not exist", script.relative_path + ) + except SyntaxError: + logger.exception("'%s' ran into syntax error", script.relative_path) + + async def start_game_button_clicked(self) -> None: + if self.game_cancel_scope: + logger.info("Game aborted") + self.game_cancel_scope.cancel() + return + if self.ui.cboAccount.currentText() == "" or ( self.ui.txtPassword.text() == "" and self.ui.txtPassword.placeholderText() == "" @@ -652,17 +687,41 @@ async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: login_response=login_response, game_launcher_config=game_launcher_config, ) - game = StartGame( - game_id=self.game_id, - config_manager=self.config_manager, - game_launcher_local_config=self.game_launcher_local_config, - game_launcher_config=game_launcher_config, - world=selected_world, - login_server=selected_world_status.login_server, - account_number=account_number, - ticket=login_response.session_ticket, - ) - await game.start_game() + self.run_startup_scripts() + logger.info("Starting game") + self.ui.btnStartGame.setText("Abort") + self.ui.btnStartGame.setToolTip("Abort running game") + self.ui.btnSwitchGame.setEnabled(False) + self.ui.actionPatch.setEnabled(False) + self.ui.btnOptions.setEnabled(False) + with trio.CancelScope() as self.game_cancel_scope: + try: + return_code = await start_game( + config_manager=self.config_manager, + game_id=self.game_id, + game_launcher_config=game_launcher_config, + game_launcher_local_config=self.game_launcher_local_config, + world=selected_world, + login_server=selected_world_status.login_server, + account_number=account_number, + ticket=login_response.session_ticket, + ) + if return_code != 0: + logger.error("Game closed unexpectedly") + else: + logger.info("Game finished") + except* MissingLaunchArgumentError: + logger.exception( + "Game launch argument missing. Please report this error if using a supported server." + ) + except* OSError: + logger.exception("Failed to start game") + + self.game_cancel_scope = None + self.reset_start_game_button() + self.ui.btnSwitchGame.setEnabled(True) + self.ui.actionPatch.setEnabled(True) + self.ui.btnOptions.setEnabled(True) async def world_queue( self, @@ -781,7 +840,7 @@ async def InitialSetup(self) -> None: # Network loading dependent self.ui.cboWorld.setEnabled(False) - self.ui.btnLogin.setEnabled(False) + self.ui.btnStartGame.setEnabled(False) self.ui.btnSwitchGame.setEnabled(False) @@ -859,7 +918,7 @@ async def game_initial_network_setup(self) -> None: return # Enable UI elements that rely on what's been loaded. self.ui.cboWorld.setEnabled(True) - self.ui.btnLogin.setEnabled(True) + self.ui.btnStartGame.setEnabled(True) await self.load_newsfeed(self.game_launcher_config) diff --git a/src/onelauncher/ui/main.ui b/src/onelauncher/ui/main.ui index 863475c5..0b8c2af8 100644 --- a/src/onelauncher/ui/main.ui +++ b/src/onelauncher/ui/main.ui @@ -311,7 +311,7 @@ - + Start your adventure! @@ -497,7 +497,7 @@ cboWorld cboAccount txtPassword - btnLogin + btnStartGame chkSaveAccount chkSavePassword txtFeed diff --git a/src/onelauncher/ui/main_uic.py b/src/onelauncher/ui/main_uic.py index e818b3e6..07f4e5e9 100644 --- a/src/onelauncher/ui/main_uic.py +++ b/src/onelauncher/ui/main_uic.py @@ -191,11 +191,11 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: self.layoutLogin.setWidget(2, QFormLayout.ItemRole.FieldRole, self.txtPassword) - self.btnLogin = QToolButton(self.widgetLogin) - self.btnLogin.setObjectName(u"btnLogin") - self.btnLogin.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) + self.btnStartGame = QToolButton(self.widgetLogin) + self.btnStartGame.setObjectName(u"btnStartGame") + self.btnStartGame.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) - self.layoutLogin.setWidget(5, QFormLayout.ItemRole.LabelRole, self.btnLogin) + self.layoutLogin.setWidget(5, QFormLayout.ItemRole.LabelRole, self.btnStartGame) self.widgetSaveSettings = QWidget(self.widgetLogin) self.widgetSaveSettings.setObjectName(u"widgetSaveSettings") @@ -251,8 +251,8 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: #endif // QT_CONFIG(shortcut) QWidget.setTabOrder(self.cboWorld, self.cboAccount) QWidget.setTabOrder(self.cboAccount, self.txtPassword) - QWidget.setTabOrder(self.txtPassword, self.btnLogin) - QWidget.setTabOrder(self.btnLogin, self.chkSaveAccount) + QWidget.setTabOrder(self.txtPassword, self.btnStartGame) + QWidget.setTabOrder(self.btnStartGame, self.chkSaveAccount) QWidget.setTabOrder(self.chkSaveAccount, self.chkSavePassword) QWidget.setTabOrder(self.chkSavePassword, self.txtFeed) QWidget.setTabOrder(self.txtFeed, self.txtStatus) @@ -322,10 +322,10 @@ def retranslateUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: self.lblAccount.setText(QCoreApplication.translate("winMain", u"Account", None)) self.lblPassword.setText(QCoreApplication.translate("winMain", u"Password", None)) #if QT_CONFIG(tooltip) - self.btnLogin.setToolTip(QCoreApplication.translate("winMain", u"Start your adventure!", None)) + self.btnStartGame.setToolTip(QCoreApplication.translate("winMain", u"Start your adventure!", None)) #endif // QT_CONFIG(tooltip) - self.btnLogin.setText(QCoreApplication.translate("winMain", u"Play", None)) - self.btnLogin.setProperty(u"qssClass", [ + self.btnStartGame.setText(QCoreApplication.translate("winMain", u"Play", None)) + self.btnStartGame.setProperty(u"qssClass", [ QCoreApplication.translate("winMain", u"text-xl", None), QCoreApplication.translate("winMain", u"px-3.5", None), QCoreApplication.translate("winMain", u"py-2", None), diff --git a/src/onelauncher/ui/start_game.ui b/src/onelauncher/ui/start_game.ui deleted file mode 100644 index 2e634c7d..00000000 --- a/src/onelauncher/ui/start_game.ui +++ /dev/null @@ -1,66 +0,0 @@ - - - startGameDialog - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 720 - 400 - - - - MainWindow - - - true - - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Abort - - - - - - - Quit - - - - - - - - - btnAbort - btnQuit - txtLog - - - - diff --git a/src/onelauncher/ui/start_game_uic.py b/src/onelauncher/ui/start_game_uic.py deleted file mode 100644 index f1e83e36..00000000 --- a/src/onelauncher/ui/start_game_uic.py +++ /dev/null @@ -1,68 +0,0 @@ -# -*- coding: utf-8 -*- - -################################################################################ -## Form generated from reading UI file 'start_game.ui' -## -## Created by: Qt User Interface Compiler version 6.7.2 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, - QMetaObject, QObject, QPoint, QRect, - QSize, QTime, QUrl, Qt) -from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, - QFont, QFontDatabase, QGradient, QIcon, - QImage, QKeySequence, QLinearGradient, QPainter, - QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QApplication, QDialog, QHBoxLayout, QPushButton, - QSizePolicy, QSpacerItem, QTextBrowser, QVBoxLayout, - QWidget) - -class Ui_startGameDialog(object): - def setupUi(self, startGameDialog: QDialog) -> None: - if not startGameDialog.objectName(): - startGameDialog.setObjectName(u"startGameDialog") - startGameDialog.setWindowModality(Qt.WindowModality.ApplicationModal) - startGameDialog.resize(720, 400) - startGameDialog.setModal(True) - self.verticalLayout = QVBoxLayout(startGameDialog) - self.verticalLayout.setObjectName(u"verticalLayout") - self.txtLog = QTextBrowser(startGameDialog) - self.txtLog.setObjectName(u"txtLog") - - self.verticalLayout.addWidget(self.txtLog) - - self.horizontalLayout = QHBoxLayout() - self.horizontalLayout.setObjectName(u"horizontalLayout") - self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - - self.horizontalLayout.addItem(self.horizontalSpacer) - - self.btnAbort = QPushButton(startGameDialog) - self.btnAbort.setObjectName(u"btnAbort") - - self.horizontalLayout.addWidget(self.btnAbort) - - self.btnQuit = QPushButton(startGameDialog) - self.btnQuit.setObjectName(u"btnQuit") - - self.horizontalLayout.addWidget(self.btnQuit) - - - self.verticalLayout.addLayout(self.horizontalLayout) - - QWidget.setTabOrder(self.btnAbort, self.btnQuit) - QWidget.setTabOrder(self.btnQuit, self.txtLog) - - self.retranslateUi(startGameDialog) - - QMetaObject.connectSlotsByName(startGameDialog) - # setupUi - - def retranslateUi(self, startGameDialog: QDialog) -> None: - startGameDialog.setWindowTitle(QCoreApplication.translate("startGameDialog", u"MainWindow", None)) - self.btnAbort.setText(QCoreApplication.translate("startGameDialog", u"Abort", None)) - self.btnQuit.setText(QCoreApplication.translate("startGameDialog", u"Quit", None)) - # retranslateUi - diff --git a/src/onelauncher/ui/start_game_window.py b/src/onelauncher/ui/start_game_window.py deleted file mode 100644 index fef3b7f7..00000000 --- a/src/onelauncher/ui/start_game_window.py +++ /dev/null @@ -1,171 +0,0 @@ -########################################################################### -# Game launcher for OneLauncher. -# -# Based on PyLotRO -# (C) 2009 AJackson -# -# Based on LotROLinux -# (C) 2007-2008 AJackson -# -# -# (C) 2019-2025 June Stepp -# -# This file is part of OneLauncher -# -# OneLauncher is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 3 of the License, or -# (at your option) any later version. -# -# OneLauncher is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with OneLauncher. If not, see . -########################################################################### -import logging - -import trio -from PySide6 import QtCore, QtWidgets - -from onelauncher.game_config import GameConfigID -from onelauncher.logs import ForwardLogsHandler -from onelauncher.qtapp import get_qapp -from onelauncher.ui_utilities import log_record_to_rich_text - -from ..addons.startup_script import run_startup_script -from ..async_utils import app_cancel_scope -from ..config_manager import ConfigManager -from ..game_launcher_local_config import GameLauncherLocalConfig -from ..game_utilities import get_game_settings_dir -from ..network.game_launcher_config import GameLauncherConfig -from ..network.world import World -from ..start_game import MissingLaunchArgumentError, start_game -from ..start_game import logger as start_game_logger -from .start_game_uic import Ui_startGameDialog - -logger = logging.getLogger(__name__) - - -class StartGame(QtWidgets.QDialog): - def __init__( - self, - game_id: GameConfigID, - config_manager: ConfigManager, - game_launcher_local_config: GameLauncherLocalConfig, - game_launcher_config: GameLauncherConfig, - world: World, - login_server: str, - account_number: str, - ticket: str, - ) -> None: - self.game_id = game_id - self.config_manager = config_manager - self.game_launcher_local_config = game_launcher_local_config - self.game_launcher_config = game_launcher_config - self.world = world - self.login_server = login_server - self.account_number = account_number - self.ticket = ticket - - super().__init__( - get_qapp().activeWindow(), - QtCore.Qt.WindowType.FramelessWindowHint, - ) - - self.ui = Ui_startGameDialog() - self.ui.setupUi(self) - - self.ui_logs_handler = ForwardLogsHandler( - new_log_callback=lambda record: self.ui.txtLog.append( - log_record_to_rich_text(record) - ), - level=logging.INFO, - ) - logger.addHandler(self.ui_logs_handler) - start_game_logger.addHandler(self.ui_logs_handler) - - # Can't quit until program finishes or is aborted - self.ui.btnQuit.setEnabled(False) - - self.ui.btnAbort.clicked.connect(self.btnAbortClicked) - self.ui.btnQuit.clicked.connect(self.btnQuitClicked) - - self.game_finished = False - - self.show() - - def reset_buttons(self) -> None: - self.game_finished = True - self.ui.btnAbort.setText("Close") - self.ui.btnQuit.setEnabled(True) - - def btnAbortClicked(self) -> None: - if self.game_finished: - start_game_logger.removeHandler(self.ui_logs_handler) - self.ui_logs_handler.close() - self.close() - else: - logger.info("*** Aborted ***") - self.game_cancel_scope.cancel() - - def btnQuitClicked(self) -> None: - if self.game_finished: - self.close() - # Close entire application - app_cancel_scope.cancel() - - def run_startup_scripts(self) -> None: - """Run enabled startup scripts""" - game_config = self.config_manager.get_game_config(self.game_id) - for script in game_config.addons.enabled_startup_scripts: - try: - logger.info("Running '%s' startup script...", script.relative_path) - run_startup_script( - script=script, - game_directory=game_config.game_directory, - documents_config_dir=get_game_settings_dir( - game_config=game_config, - launcher_local_config=self.game_launcher_local_config, - ), - ) - except FileNotFoundError: - logger.exception( - "'%s' startup script does not exist", script.relative_path - ) - except SyntaxError: - logger.exception("'%s' ran into syntax error", script.relative_path) - - async def start_game(self) -> None: - self.game_finished = False - self.ui.btnAbort.setText("Abort") - self.run_startup_scripts() - - self.show() - logger.info("*** Started ***") - with trio.CancelScope() as self.game_cancel_scope: - try: - return_code = await start_game( - config_manager=self.config_manager, - game_id=self.game_id, - game_launcher_config=self.game_launcher_config, - game_launcher_local_config=self.game_launcher_local_config, - world=self.world, - login_server=self.login_server, - account_number=self.account_number, - ticket=self.ticket, - ) - if return_code != 0: - logger.error("Game closed unexpectedly") - else: - logger.info("*** Finished ***") - except* MissingLaunchArgumentError: - logger.exception( - "Game launch argument missing. Please report this error if using a supported server." - ) - except* OSError: - logger.exception("Failed to start game") - - self.reset_buttons() From 7fe88db6b6888127173a15f94ad7e42e8b1ed0c3 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 13 Dec 2025 15:49:09 -0600 Subject: [PATCH 37/97] ci: add status checks --- .github/workflows/status_checks.yml | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/status_checks.yml diff --git a/.github/workflows/status_checks.yml b/.github/workflows/status_checks.yml new file mode 100644 index 00000000..711c1657 --- /dev/null +++ b/.github/workflows/status_checks.yml @@ -0,0 +1,40 @@ +name: Status checks +on: + push: + pull_request: + +permissions: {} + +jobs: + status_checks: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, macos-15-intel, windows-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install Python + uses: actions/setup-python@v6 + with: + python-version-file: "pyproject.toml" + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - run: uv sync --locked + + - name: Install PySide6 dependencies on Linux + if: runner.os == 'Linux' + run: sudo apt-get install libegl1 + + - name: Lint + run: ruff check --output-format=github + - name: Type check + run: mypy . + - name: Unit test + run: pytest + - name: Check formatting + run: ruff format --check --output-format=github From c5a991faba27fabf2b81722423b30d684033c5df Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 13 Dec 2025 17:46:29 -0600 Subject: [PATCH 38/97] test(cli): update mocking to remove `QApplication` initialization --- tests/onelauncher/test_cli.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/tests/onelauncher/test_cli.py b/tests/onelauncher/test_cli.py index bb4caf5e..c79dd4b6 100644 --- a/tests/onelauncher/test_cli.py +++ b/tests/onelauncher/test_cli.py @@ -14,9 +14,6 @@ ConfigManager, ) from onelauncher.game_config import GameConfig, GameType, generate_game_config_id -from onelauncher.main_window import MainWindow -from onelauncher.qtapp import get_qapp -from onelauncher.setup_wizard import SetupWizard from onelauncher.utilities import CaseInsensitiveAbsolutePath from onelauncher.wine.config import WineConfigSection @@ -79,8 +76,7 @@ async def test_normal( assert app([]) == 0 async_mock.assert_called_once() - get_qapp() - main_window_mock = mocker.patch.object(MainWindow, "run") + main_window_mock = mocker.patch.object(main, "MainWindow", autospec=True) await async_mock.call_args.kwargs["entry"]() main_window_mock.assert_called_once() @@ -93,12 +89,12 @@ async def test_no_config(app: cyclopts.App, mocker: MockerFixture) -> None: assert app([]) == 0 async_mock.assert_called_once() - get_qapp() - mock = mocker.patch.object(SetupWizard, "exec") - mock.return_value = QtWidgets.QDialog.DialogCode.Rejected + mock = mocker.patch.object(main, "SetupWizard") + mock_instance = mock.return_value + mock_instance.exec.return_value = QtWidgets.QDialog.DialogCode.Rejected await async_mock.call_args.kwargs["entry"]() - mock.assert_called_once() + mock_instance.exec.assert_called_once() async def test_no_games( @@ -112,16 +108,15 @@ async def test_no_games( assert app([]) == 0 async_mock.assert_called_once() - get_qapp() mocker.patch.object(QtWidgets.QMessageBox, "information") - spy = mocker.spy(main, "SetupWizard") - mock = mocker.patch.object(SetupWizard, "exec") - mock.return_value = QtWidgets.QDialog.DialogCode.Rejected + mock = mocker.patch.object(main, "SetupWizard") + mock_instance = mock.return_value + mock_instance.exec.return_value = QtWidgets.QDialog.DialogCode.Rejected await async_mock.call_args.kwargs["entry"]() - spy.assert_called_once() - assert spy.call_args.kwargs["game_selection_only"] is True mock.assert_called_once() + assert mock.call_args.kwargs["game_selection_only"] is True + mock_instance.exec.assert_called_once() def test_invalid_program_config( @@ -173,8 +168,7 @@ async def test_invalid_program_config_load_backup( assert app([]) == 0 async_mock.assert_called_once() - get_qapp() - main_window_mock = mocker.patch.object(MainWindow, "run") + main_window_mock = mocker.patch.object(main, "MainWindow", autospec=True) await async_mock.call_args.kwargs["entry"]() main_window_mock.assert_called_once() From 5ad8239393baf54715b6e2ecfba12957b18be29f Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 13 Dec 2025 18:52:14 -0600 Subject: [PATCH 39/97] ci: update `build.yml` --- .github/workflows/build.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 286c5766..941a66cb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,6 @@ -name: build +name: Build -permissions: - contents: write # For making release +permissions: { } on: push: @@ -11,7 +10,7 @@ on: jobs: build: - name: Build for ${{ matrix.os }} + name: ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -54,15 +53,15 @@ jobs: shell: msys2 {0} run: make -C src/run_patch_client - # uv + # Python # Can't use uv python with Nuitka yet # See https://github.com/Nuitka/Nuitka/issues/3331 - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version-file: "pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 with: activate-environment: true - run: uv sync --locked --no-dev --group build @@ -110,11 +109,13 @@ jobs: path: build/out/${{ matrix.artifact_rename }} if-no-files-found: error release: - # Only make a release for new tags + # Only make a release for new tags. if: startsWith(github.ref, 'refs/tags/') needs: [build] runs-on: ubuntu-latest name: Make draft release and upload artifacts + permissions: + contents: write # For making the release. steps: - name: Download build artifacts uses: actions/download-artifact@v4 From 238b85333222bda11b843b8feadcb0783fec216f Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 13 Dec 2025 21:51:57 -0600 Subject: [PATCH 40/97] ci: add spell checker --- .github/workflows/status_checks.yml | 2 ++ CHANGELOG.md | 2 +- build/nuitka_compile.py | 2 +- flake.nix | 2 +- pyproject.toml | 7 +++++++ src/onelauncher/__about__.py | 2 +- src/onelauncher/addon_manager.py | 14 ++++++------- .../addons/schemas/lotrointerface_feed.xsd | 2 +- .../schemas/vscode-lotro-api/lotroplugin.xsd | 4 ++-- src/onelauncher/cli.py | 2 +- src/onelauncher/config_manager.py | 4 ++-- src/onelauncher/game_account_config.py | 5 ----- src/onelauncher/game_config.py | 2 +- src/onelauncher/game_launcher_local_config.py | 4 ++-- src/onelauncher/game_utilities.py | 4 ++-- src/onelauncher/logs.py | 2 +- src/onelauncher/main.py | 4 ++-- src/onelauncher/main_window.py | 4 ++-- .../network/game_launcher_config.py | 2 +- src/onelauncher/network/world.py | 2 +- src/onelauncher/network/world_login_queue.py | 4 ++-- src/onelauncher/patch_game.py | 2 +- src/onelauncher/patch_game_window.py | 2 +- src/onelauncher/schemas/v1x_config.xsd | 2 +- src/onelauncher/start_game.py | 8 +++---- src/onelauncher/ui/setup_wizard_uic.py | 2 +- src/onelauncher/utilities.py | 8 +++---- src/onelauncher/v1x_config_migrator.py | 8 +++---- src/onelauncher/wine_environment.py | 10 ++++----- tests/onelauncher/test_utilities.py | 10 ++++----- uv.lock | 21 +++++++++++++++++++ 31 files changed, 87 insertions(+), 62 deletions(-) diff --git a/.github/workflows/status_checks.yml b/.github/workflows/status_checks.yml index 711c1657..44aaa6bd 100644 --- a/.github/workflows/status_checks.yml +++ b/.github/workflows/status_checks.yml @@ -36,5 +36,7 @@ jobs: run: mypy . - name: Unit test run: pytest + - name: Check for spelling mistakes + run: typos - name: Check formatting run: ruff format --check --output-format=github diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a07c7f6..f98a5eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ This update has quite a few small fixes and improvements. The full changelog is ### Testing -- Add `test_allow_uknown_config_keys` +- Add `test_allow_unknown_config_keys` - Increase strictness of pytest config ## 2.0.1 (2024-08-03) diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index 1b924101..e1b18aa1 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -23,7 +23,7 @@ def main( nuitka_arguments = [ f"--output-dir={Path(__file__) / 'out'}", "--onefile" if onefile_mode else "--standalone", - "--python-flag=-m", # Package mode. Compile as "pakcage.__main__" + "--python-flag=-m", # Package mode. Compile as "package.__main__" "--python-flag=isolated", "--python-flag=no_docstrings", "--warn-unusual-code", diff --git a/flake.nix b/flake.nix index 4a8ce138..87003509 100644 --- a/flake.nix +++ b/flake.nix @@ -222,7 +222,7 @@ # Hatchling (our build system) has a dependency on the `editables` package when building editables. # # In normal Python flows this dependency is dynamically handled, and doesn't need to be explicitly declared. - # This behaviour is documented in PEP-660. + # This behavior is documented in PEP-660. # # With Nix the dependency needs to be explicitly declared. nativeBuildInputs = diff --git a/pyproject.toml b/pyproject.toml index cac67f35..b34daf1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ lint = [ "ruff>=0.14.1", # Newer versions have more accurate types. "PySide6-Essentials>=6.10.0", + "typos>=1.40.0", ] test = [ "pytest>=8.3.2", @@ -168,3 +169,9 @@ exclude = ["\\.mypy_test_data\\.py"] # makes it to a release module = ["feedparser"] ignore_missing_imports = true + +[tool.typos] +files.extend-exclude = ["locale/"] +default.unicode = false +default.locale = "en-us" +default.extend-ignore-re = ["(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"] diff --git a/src/onelauncher/__about__.py b/src/onelauncher/__about__.py index c04581d3..884e8b89 100644 --- a/src/onelauncher/__about__.py +++ b/src/onelauncher/__about__.py @@ -2,7 +2,7 @@ from packaging.version import Version -# Metadata has been temporily manually entered to work with Nuitka. +# Metadata has been temporarily manually entered to work with Nuitka. # See https://github.com/Nuitka/Nuitka/issues/2965 # metadata = importlib.metadata.metadata(__package__) # noqa: ERA001 diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index 7296fd39..ff93d108 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -298,7 +298,7 @@ def __init__( self.actionShowAddonInFileManagerSelected ) - # Will only show when a downlaod is hapenning + # Will only show when a download is happening self.ui.progressBar.setVisible(False) get_check_for_updates_icon = partial(qtawesome.icon, "fa5s.sync-alt") @@ -638,7 +638,7 @@ def isCurrentDBOutdated(self) -> bool: """ tables_dict: dict[str, list[str]] = {} - # SQL returns all the columns in all the tables labled with what table + # SQL returns all the columns in all the tables labeled with what table # they're from column_data: tuple[str, str] for column_data in self.c.execute( @@ -1070,7 +1070,7 @@ def clean_temp_addon_folder(self, addon_dir: Path) -> None: """Scans temp folder for invalid folder names like "ui" or "plugins" and moves stuff out of them. Addon authors put files in invalid folders when they want the user to extract - the file somewere higher up the folder tree than where their + the file somewhere higher up the folder tree than where their work ends up. This is usually done for user convenience. Args: @@ -1132,7 +1132,7 @@ def generateCompendiumFile( case of plugins it should be the author's name. This has to be the addon root dir while it is still in a temporary directory - for propper .plugin file detection. + for proper .plugin file detection. interface_id (str): [description] addon_type (AddonType): The type of the addon. table (str): The database table name for the addon type. Used to get remote @@ -1801,7 +1801,7 @@ def tabBarIndexChanged(self, index: int) -> None: elif self.SOURCE_TAB_NAMES[index] == "Find More": self.ui.stackedWidgetSource.setCurrentWidget(self.ui.pageRemote) - # Handle the first time this tab is swtiched to. + # Handle the first time this tab is switched to. # Populate remote addons tables if not done already. if self.ui.tableSkins not in self.tables_loaded and self.loadRemoteAddons(): self.getOutOfDateAddons() @@ -1829,7 +1829,7 @@ def loadRemoteAddons(self) -> bool: def unescape_lotrointerface_feed_unicode(self, escaped_string: str) -> str: """ Convert feed escaped characters to Unicode characters. This shouold be used with - strings that have alread had the XML unesaaped. + strings that have already had the XML unesaaped. Unicode characters in LotroInterface feeds are escaped with an ampersand followed by the Unicode character number. Ex. `&1088`. @@ -1998,7 +1998,7 @@ def contextMenuRequested( ]: menu.addAction(self.ui.actionUpdateAddon) - # If addon has a statup script + # If addon has a startup script if self.context_menu_selected_interface_ID: relative_script_path = self.getRelativeStartupScriptFromInterfaceID( table=self.context_menu_selected_table, diff --git a/src/onelauncher/addons/schemas/lotrointerface_feed.xsd b/src/onelauncher/addons/schemas/lotrointerface_feed.xsd index 46b80fac..51b436a0 100644 --- a/src/onelauncher/addons/schemas/lotrointerface_feed.xsd +++ b/src/onelauncher/addons/schemas/lotrointerface_feed.xsd @@ -1,4 +1,4 @@ - + diff --git a/src/onelauncher/addons/schemas/vscode-lotro-api/lotroplugin.xsd b/src/onelauncher/addons/schemas/vscode-lotro-api/lotroplugin.xsd index e196adbd..518eb8c3 100644 --- a/src/onelauncher/addons/schemas/vscode-lotro-api/lotroplugin.xsd +++ b/src/onelauncher/addons/schemas/vscode-lotro-api/lotroplugin.xsd @@ -23,7 +23,7 @@ - The version that will be displayed in the "/plugins list", "/plugins refresh" and plugin manager lists. This value can also be used programatically for tagging saved data and automatically processing data updates. + The version that will be displayed in the "/plugins list", "/plugins refresh" and plugin manager lists. This value can also be used programmatically for tagging saved data and automatically processing data updates. @@ -37,7 +37,7 @@ - The realtive path to a .JPG or .TGA file. Note, if the file is greater than 32x32 it will be cropped to 32x32. If the image is less than 32x32 it will be tiled. This image will be displayed in the Turbine Plugin Manager + The relative path to a .JPG or .TGA file. Note, if the file is greater than 32x32 it will be cropped to 32x32. If the image is less than 32x32 it will be tiled. This image will be displayed in the Turbine Plugin Manager diff --git a/src/onelauncher/cli.py b/src/onelauncher/cli.py index cdbfa332..3b32e654 100644 --- a/src/onelauncher/cli.py +++ b/src/onelauncher/cli.py @@ -320,7 +320,7 @@ def validate_game_param( if isinstance(value, _GameParamGameType | GameConfigID) and _config_manager: parse_game_arg(game_arg=value, config_manager=_config_manager) - # --- Comands --- + # --- Commands --- # They all return an exit code integer. @app.meta.meta.default diff --git a/src/onelauncher/config_manager.py b/src/onelauncher/config_manager.py index 308705fc..bff3ad0a 100644 --- a/src/onelauncher/config_manager.py +++ b/src/onelauncher/config_manager.py @@ -35,8 +35,8 @@ def _structure_onelauncher_locale( return available_locales[lang_tag] -def _unstructure_startup_script(startup_scirpt: StartupScript) -> str: - return str(startup_scirpt.relative_path) +def _unstructure_startup_script(startup_script: StartupScript) -> str: + return str(startup_script.relative_path) def _structure_startup_script( diff --git a/src/onelauncher/game_account_config.py b/src/onelauncher/game_account_config.py index 9a082ff6..7582b09c 100644 --- a/src/onelauncher/game_account_config.py +++ b/src/onelauncher/game_account_config.py @@ -17,11 +17,6 @@ class GameAccountConfig: ) -@attrs.frozen -class GameAcccountNoUsername(GameAccountConfig): - username: str = attrs.field(default="", init=False) - - @attrs.frozen class GameAccountsConfig(Config): accounts: tuple[GameAccountConfig, ...] diff --git a/src/onelauncher/game_config.py b/src/onelauncher/game_config.py index acb3b385..581f7a44 100644 --- a/src/onelauncher/game_config.py +++ b/src/onelauncher/game_config.py @@ -77,7 +77,7 @@ class GameConfig(Config): ), ) newsfeed: str | None = config_field( - default=None, help="URL of the feed (RSS, ATOM, ect) to show in the launcher" + default=None, help="URL of the feed (RSS, ATOM, etc) to show in the launcher" ) environment: dict[str, str] = config_field( default={}, diff --git a/src/onelauncher/game_launcher_local_config.py b/src/onelauncher/game_launcher_local_config.py index e9923e94..e0497f40 100644 --- a/src/onelauncher/game_launcher_local_config.py +++ b/src/onelauncher/game_launcher_local_config.py @@ -104,7 +104,7 @@ def from_config_xml(cls: type[Self], config_xml: str) -> Self: @cached(LRUCache(maxsize=128)) async def from_game_dir( cls: type[Self], - *, # Keyword only, so caching is consistant + *, # Keyword only, so caching is consistent game_directory: CaseInsensitiveAbsolutePath, game_type: GameType, ) -> Self | None: @@ -146,7 +146,7 @@ def _edit_config_xml_app_setting( def to_config_xml(self, existing_xml: str | None = None) -> str: """ - CODE NOT IN USE YET. TODO: Clear or idealy replace `GameLauncherLocalConfig.from_game` + CODE NOT IN USE YET. TODO: Clear or ideally replace `GameLauncherLocalConfig.from_game` cache when a launcher config file is updated. Serialize into valid .launcherconfig text. diff --git a/src/onelauncher/game_utilities.py b/src/onelauncher/game_utilities.py index 15150ccf..cc8dac9c 100644 --- a/src/onelauncher/game_utilities.py +++ b/src/onelauncher/game_utilities.py @@ -34,7 +34,7 @@ def find_game_dir_game_type(game_dir: CaseInsensitiveAbsolutePath) -> GameType: with contextlib.suppress(ValueError): return GameType(launcher_config_path.stem.upper()) - # Try determing game type from datacenter game name + # Try determining game type from datacenter game name try: launcher_config = GameLauncherLocalConfig.from_config_xml( launcher_config_path.read_text(encoding="UTF-8") @@ -61,7 +61,7 @@ def get_game_settings_dir( ) -> CaseInsensitiveAbsolutePath: """ The folder in the user documents dir that the game stores information in. - This includes addons, screenshots, user config files, ect + This includes addons, screenshots, user config files, etc """ return game_config.game_settings_directory or get_default_game_settings_dir( launcher_local_config=launcher_local_config diff --git a/src/onelauncher/logs.py b/src/onelauncher/logs.py index 61ed54d2..0bc701eb 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -133,7 +133,7 @@ def emit(self, record: logging.LogRecord) -> None: class ExternalProcessLogsFilter(logging.Filter): """ Filter that sets the `LogRecord` process ID to the value for the key - `EXTERNAL_PROCESS_ID_KEY` in the `extra` logging keyward argument. + `EXTERNAL_PROCESS_ID_KEY` in the `extra` logging keyword argument. Used when logging output from external processes. """ diff --git a/src/onelauncher/main.py b/src/onelauncher/main.py index 175223e8..969eb57a 100644 --- a/src/onelauncher/main.py +++ b/src/onelauncher/main.py @@ -25,7 +25,7 @@ def show_invalid_config_dialog( """ Returns: None: When `backup_available` is `False`. - bool: Wether the user wants to load the backup. + bool: Whether the user wants to load the backup. """ _ = get_qapp() dialog = QtWidgets.QDialog() @@ -48,7 +48,7 @@ def verify_configs(config_manager: ConfigManager) -> bool: the configs are valid. Returns: - bool: Wether the configs after valid after all user prompting/potential backup + bool: Whether the configs after valid after all user prompting/potential backup loading. """ try: diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 05a1a1ca..029a8fb8 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -426,7 +426,7 @@ async def start_game_button_clicked(self) -> None: return if not self.game_launcher_config: - logger.error("Game launcher network config isn't laoded") + logger.error("Game launcher network config isn't loaded") return await self.start_game(game_launcher_config=self.game_launcher_config) @@ -436,7 +436,7 @@ def accounts_index_changed(self, new_index: int) -> None: # No selection if new_index == -1: self.ui.chkSaveAccount.setChecked(False) - # In case it's still in it's inital unchecked state. + # In case it's still in it's initial unchecked state. self.chk_save_account_toggled(self.ui.chkSaveAccount.isChecked()) return diff --git a/src/onelauncher/network/game_launcher_config.py b/src/onelauncher/network/game_launcher_config.py index 4b4f6a7d..9e082622 100644 --- a/src/onelauncher/network/game_launcher_config.py +++ b/src/onelauncher/network/game_launcher_config.py @@ -149,7 +149,7 @@ def from_xml(cls: type[Self], appsettings_config_xml: str) -> Self: ) from e except NoGameClientFilenameError as e: raise GameLauncherConfigParseError( - "Config doesn't include any client filenames of a suppored client type" + "Config doesn't include any client filenames of a supported client type" ) from e @classmethod diff --git a/src/onelauncher/network/world.py b/src/onelauncher/network/world.py index d6651598..1cef20ab 100644 --- a/src/onelauncher/network/world.py +++ b/src/onelauncher/network/world.py @@ -75,7 +75,7 @@ async def get_status(self) -> WorldStatus: if not status_dict["queueurls"]: # There have yet to be any modern examples of queue URLs not being # returned when the world is up, but there has been at least one - # example of it hapening while the world is down. + # example of it happening while the world is down. # See . raise WorldUnavailableError(f"{self} world unavailable") queue_urls: tuple[str, ...] = tuple( diff --git a/src/onelauncher/network/world_login_queue.py b/src/onelauncher/network/world_login_queue.py index 372ce1f9..bb9dd34c 100644 --- a/src/onelauncher/network/world_login_queue.py +++ b/src/onelauncher/network/world_login_queue.py @@ -12,7 +12,7 @@ class JoinWorldQueueResult(NamedTuple): class WorldQueueResultXMLParseError(Exception): - """Error with content/formatting of world queue respone XML""" + """Error with content/formatting of world queue response XML""" class JoinWorldQueueFailedError(Exception): @@ -64,7 +64,7 @@ async def join_queue(self) -> JoinWorldQueueResult: Raises: HTTPError: Network error WorldQueueResultXMLParseError: Error with content/formatting of - world queue respone XML + world queue response XML JoinWorldQueueFailedError: Failed to join world login queue """ response = await get_httpx_client(self._login_queue_url).post( diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index 23b5f848..fe65abf6 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -154,7 +154,7 @@ async def patch_game( if os.name == "nt": # The directory with TTEPatchClient.dll has to be in the PATH for - # patchclient.dll to find it when OneLauncher is compilled with Nuitka. + # patchclient.dll to find it when OneLauncher is compiled with Nuitka. environment = MappingProxyType( environment | { diff --git a/src/onelauncher/patch_game_window.py b/src/onelauncher/patch_game_window.py index fbd3532e..c9647073 100644 --- a/src/onelauncher/patch_game_window.py +++ b/src/onelauncher/patch_game_window.py @@ -128,7 +128,7 @@ def btnStopClicked(self) -> None: logger.info("*** Aborted ***") async def keep_progress_bar_updated(self) -> None: - # Will be cancled once the patching window is closed. + # Will be canceled once the patching window is closed. while True: if self.progress_monitor: progress = self.progress_monitor.get_patching_progress() diff --git a/src/onelauncher/schemas/v1x_config.xsd b/src/onelauncher/schemas/v1x_config.xsd index a0b8defb..14e5d674 100644 --- a/src/onelauncher/schemas/v1x_config.xsd +++ b/src/onelauncher/schemas/v1x_config.xsd @@ -60,7 +60,7 @@ element. --> - + diff --git a/src/onelauncher/start_game.py b/src/onelauncher/start_game.py index c86ed8c9..204c9098 100644 --- a/src/onelauncher/start_game.py +++ b/src/onelauncher/start_game.py @@ -46,7 +46,7 @@ async def get_launch_args( Raises: MissingLaunchArgumentError - The game's launch arguents can be found by running the client with no arguments through WINE. + The game's launch arguments can be found by running the client with no arguments through WINE. As of 2024/07/10, the output for LOTRO is: ```text -a, --account : : Specifies the account name to logon with. @@ -81,11 +81,11 @@ async def get_launch_args( -u, --user : : Character Name you would like to play --voicenetdelay : : Specifies the voice network delay threshold in milliseconds. --voiceoff : Disables the Voice chat system. - --wfilelog : <64-bitmask> : activates file logging for the specified weenie event types. Alternately, logtype enums seperated by ',' are enummapped and or'ed together. - --wprintlog : <64-bitmask> : activates print logging for the specified weenie event types. Alternately, logtype enums seperated by ',' are enummapped and or'ed together. + --wfilelog : <64-bitmask> : activates file logging for the specified weenie event types. Alternately, logtype enums separated by ',' are enummapped and or'ed together. + --wprintlog : <64-bitmask> : activates print logging for the specified weenie event types. Alternately, logtype enums separated by ',' are enummapped and or'ed together. ``` - A couple aditional notes on these options: + A couple additional notes on these options: - When possible, the information from `GameLauncherConfig` should be used over hard coded values. - The `--prefs` option also changes the game settings directory to the parent folder diff --git a/src/onelauncher/ui/setup_wizard_uic.py b/src/onelauncher/ui/setup_wizard_uic.py index 05923679..ebc8336e 100644 --- a/src/onelauncher/ui/setup_wizard_uic.py +++ b/src/onelauncher/ui/setup_wizard_uic.py @@ -210,7 +210,7 @@ def retranslateUi(self, Wizard: QWizard) -> None: #endif // QT_CONFIG(tooltip) self.upPriorityButton.setText(QCoreApplication.translate("Wizard", u"\u2191", None)) self.addGameButton.setText(QCoreApplication.translate("Wizard", u"Add Game", None)) - self.dataDeletionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Exisiting Games Data", None)) + self.dataDeletionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Existing Games Data", None)) self.dataDeletionWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"Some of your game installations are already registered with OneLauncher. You can choose to have their settings and accounts either kept or reset. Unselected games are always removed.", None)) self.groupBox.setTitle(QCoreApplication.translate("Wizard", u"What should happen to existing game data?", None)) self.keepDataRadioButton.setText(QCoreApplication.translate("Wizard", u"Keep it", None)) diff --git a/src/onelauncher/utilities.py b/src/onelauncher/utilities.py index 13be7241..dc499e41 100644 --- a/src/onelauncher/utilities.py +++ b/src/onelauncher/utilities.py @@ -58,10 +58,10 @@ class CaseInsensitiveAbsolutePath(Path): addon folders or anything else used by the games or in the WINE prefixes. """ - _flavour = ( - pathlib._windows_flavour # type: ignore[attr-defined] + _flavour = ( # spellchecker:disable-line + pathlib._windows_flavour # type: ignore[attr-defined] # spellchecker:disable-line if os.name == "nt" - else pathlib._posix_flavour # type: ignore[attr-defined] + else pathlib._posix_flavour # type: ignore[attr-defined] # spellchecker:disable-line ) def __new__(cls, *pathsegments: StrPath) -> Self: @@ -83,7 +83,7 @@ def _get_real_path_from_fully_case_insensitive_path( # If root doesn't exist, nothing else can be checked. return start_path - # Range starts at 1 to ingore root which has just been checked. + # Range starts at 1 to ignore root which has just been checked. start_index = 1 else: start_index = len(known_to_exist_base_path.parts) diff --git a/src/onelauncher/v1x_config_migrator.py b/src/onelauncher/v1x_config_migrator.py index 523ca35e..f22c4ddc 100644 --- a/src/onelauncher/v1x_config_migrator.py +++ b/src/onelauncher/v1x_config_migrator.py @@ -46,7 +46,7 @@ class V1xGameAccounts: @attrs.frozen -class V1xStatupScripts: +class V1xStartupScripts: startup_scripts: tuple[StartupScript, ...] = () @@ -63,7 +63,7 @@ class V1xGameConfig: language: OneLauncherLocale | None = None patch_client: str = "patchclient.dll" accounts: V1xGameAccounts | None = None - startup_scripts: V1xStatupScripts | None = None + startup_scripts: V1xStartupScripts | None = None V1xGameType: TypeAlias = Literal["LOTRO", "LOTRO.Test", "DDO", "DDO.Test"] @@ -167,11 +167,11 @@ def get_converter() -> cattrs.Converter: ) converter.register_structure_hook(V1xGameConfig, game_config_structure_hook) structure_startup_scripts = cattrs.gen.make_dict_structure_fn( - V1xStatupScripts, + V1xStartupScripts, converter=converter, startup_scripts=cattrs.override(rename="script"), ) - converter.register_structure_hook(V1xStatupScripts, structure_startup_scripts) + converter.register_structure_hook(V1xStartupScripts, structure_startup_scripts) converter.register_structure_hook(bool, _structure_bool) converter.register_structure_hook(ClientType, _structure_client_type) converter.register_structure_hook(OneLauncherLocale, _structure_locale) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 538b3742..64a908a3 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -54,7 +54,7 @@ logger = logging.getLogger(__name__) -MOLTENVK_VERSION = "1.2.10-cxp20241028-UE4hack-zeroinit" +MOLTENVK_VERSION = "1.2.10-cxp20241028-UE4hack-zeroinit" # spellchecker:disable-line MOLTENVK_URL = "https://github.com/Sikarugir-App/MoltenVK/releases/download/v1.2.10/macos-1.2.10-cxp20241028-UE4hack-zeroinit.tar.xz" if sys.platform == "darwin": WINE_VERSION = "staging-10.20" @@ -237,12 +237,12 @@ def dxvk_setup(self) -> None: self.dlgDownloader.reset() self.dlgDownloader.setLabelText("Extracting DXVK...") self.dlgDownloader.setValue(99) - self._dxvk_extracor(download_path) + self._dxvk_extractor(download_path) self.dlgDownloader.setValue(100) self._dxvk_injector() - def _dxvk_extracor(self, archive_path: Path) -> None: + def _dxvk_extractor(self, archive_path: Path) -> None: with TemporaryDirectory() as temp_dir_name: temp_dir = Path(temp_dir_name) @@ -344,7 +344,7 @@ def setup_files(self) -> None: wine_management = WineManagement() -ESYNC_MINIMUM_OPEN_FILE_LMIT = 524288 +ESYNC_MINIMUM_OPEN_FILE_LIMIT = 524288 def get_wine_process_args( @@ -382,7 +382,7 @@ def get_wine_process_args( # Enable ESYNC if open file limit is high enough. if (path := Path("/proc/sys/fs/file-max")).exists() and int( path.read_text() - ) >= ESYNC_MINIMUM_OPEN_FILE_LMIT: + ) >= ESYNC_MINIMUM_OPEN_FILE_LIMIT: edited_environment["WINEESYNC"] = "1" # Enable FSYNC. It overrides ESYNC and will only be used if diff --git a/tests/onelauncher/test_utilities.py b/tests/onelauncher/test_utilities.py index 10e2119f..7929256c 100644 --- a/tests/onelauncher/test_utilities.py +++ b/tests/onelauncher/test_utilities.py @@ -36,7 +36,7 @@ def test_no_matching_path(self, tmp_path: Path) -> None: @pytest.mark.skipif( sys.platform in ("win32", "darwin"), - reason="Windows and MacOS filesystems are case-insentive already by default, so there can only be one match.", + reason="Windows and MacOS filesystems are case-insensitive already by default, so there can only be one match.", ) def test_multiple_matches( self, tmp_path: Path, caplog: pytest.LogCaptureFixture @@ -60,7 +60,7 @@ def test_multiple_matches( @pytest.mark.skipif( sys.platform in ("win32", "darwin"), - reason="Windows and MacOS filesystems are case-insentive already by default, so there can only be one match.", + reason="Windows and MacOS filesystems are case-insensitive already by default, so there can only be one match.", ) def test_multiple_matches_with_one_exact_match( self, tmp_path: Path, caplog: pytest.LogCaptureFixture @@ -82,7 +82,7 @@ def test_multiple_matches_with_one_exact_match( @pytest.mark.skipif( sys.platform == "win32", - reason="Extra permisions are needed to make symlinks on Windows.", + reason="Extra permissions are needed to make symlinks on Windows.", ) def test_symlink(self, tmp_path: Path) -> None: folder = tmp_path / "folder" @@ -96,7 +96,7 @@ def test_symlink(self, tmp_path: Path) -> None: @pytest.mark.skipif( sys.platform == "win32", - reason="Extra permisions are needed to make symlinks on Windows.", + reason="Extra permissions are needed to make symlinks on Windows.", ) def test_broken_symlink(self, tmp_path: Path) -> None: folder = tmp_path / "folder" @@ -157,7 +157,7 @@ def test_relative_to(self, tmp_path: Path) -> None: def test_relative_to_is_normal_path(self, tmp_path: Path) -> None: """ `PurePath.relative_to` by its nature generates non-absolute paths. - Thus, `CaseInsenstivieAbsolutePath.relative_to` should a regular path + Thus, `CaseInsensitiveAbsolutePath.relative_to` should a regular path """ relative_to_path = CaseInsensitiveAbsolutePath( tmp_path / "somepath" diff --git a/uv.lock b/uv.lock index cfeb9110..a996cd2f 100644 --- a/uv.lock +++ b/uv.lock @@ -576,12 +576,14 @@ dev = [ { name = "pytest-trio" }, { name = "ruff" }, { name = "types-cachetools" }, + { name = "typos" }, ] lint = [ { name = "mypy" }, { name = "pyside6-essentials" }, { name = "ruff" }, { name = "types-cachetools" }, + { name = "typos" }, ] test = [ { name = "mypy" }, @@ -634,12 +636,14 @@ dev = [ { name = "pytest-trio", specifier = ">=0.8.0" }, { name = "ruff", specifier = ">=0.14.1" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, + { name = "typos", specifier = ">=1.40.0" }, ] lint = [ { name = "mypy", specifier = ">=1.18.2" }, { name = "pyside6-essentials", specifier = ">=6.10.0" }, { name = "ruff", specifier = ">=0.14.1" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, + { name = "typos", specifier = ">=1.40.0" }, ] test = [ { name = "mypy" }, @@ -3355,6 +3359,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typos" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/f0/8d988732b10ef72ed82900b055590a210a5ae423b4088d17fa961305ed6b/typos-1.40.0.tar.gz", hash = "sha256:5cb1a04a6291fa1fa358ce6d8cd5b50e396d0a306466b792ac6c246066b1780f", size = 1765534, upload-time = "2025-11-26T20:54:53.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/dd/64ceee60d4d1d7d0c90dac8d3bb5ebfcc2e1d1e5b5166f3284abc4052e45/typos-1.40.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:71441cb06044baba29911e4b6500a85b2e915736d1fc0a54d5f575addb12a307", size = 3507274, upload-time = "2025-11-26T20:54:39.564Z" }, + { url = "https://files.pythonhosted.org/packages/18/db/64f7146b86e912041aafe275f627081e4bd005f71932f5280cf0c3944f2b/typos-1.40.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:269e411f342126b06f38936eba9d391a41442c17425e57068797c9e6997e3fca", size = 3391108, upload-time = "2025-11-26T20:54:41.462Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f1/1eead106cc0c025319d23ccff78aa7b9c86a8a918f62359180f119deb96b/typos-1.40.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78d4d7be7e6f61c1bbec01abd9ee2e08254f633b845a9d2c5786051832c3e0c1", size = 8215390, upload-time = "2025-11-26T20:54:43.01Z" }, + { url = "https://files.pythonhosted.org/packages/82/c9/dc027ec8819d1c652d80ac2c3b6216dcc4c6d198907e2c2ed29cd4710685/typos-1.40.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4dbc419aed7cd4b9e8ec71a28045a3b6262fa5a41170734a3fc4dfdf1e7d7a51", size = 7192543, upload-time = "2025-11-26T20:54:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1a/db/f6fef0f4d173f501b469a90ed3d462bf7e4301a28507b7914cefa1d78ca1/typos-1.40.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0701400559effc6806a043dac55e1b77fc09e540661bf4315eaf55a628138214", size = 7729297, upload-time = "2025-11-26T20:54:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/bb5b415cd352168550170ba5bb7c6b1c53fe457084df5ff07488c525dca6/typos-1.40.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:41ed67ad7cba724841f72d5c7c69de20f79dbee52917fb1fb5f3efa327d44cd3", size = 7107127, upload-time = "2025-11-26T20:54:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/1d/92/1a39cea9ba7369555ed3f540b48ed5fd6f059ec89e24fb87dd21df69bf2a/typos-1.40.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:47764e89fca194b77ff65741b1527210096e39984b2c460ba5bc4868ea05ea88", size = 8141765, upload-time = "2025-11-26T20:54:49.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/64/7d28b539b6d09b59ed3ea13f54e74e0cb8409fff3174928ee2f98ca349fb/typos-1.40.0-py3-none-win32.whl", hash = "sha256:9cd19efd5a3abcc788770ffb9a070f39da0d97c4aadd7eaf471e744a02002464", size = 3065525, upload-time = "2025-11-26T20:54:51.112Z" }, + { url = "https://files.pythonhosted.org/packages/49/0a/e324e17a0407dfe2459ecd8c467b0b3953ec5c553bd552949fdc238bec91/typos-1.40.0-py3-none-win_amd64.whl", hash = "sha256:69c47f0b899bc62d87d6fc431824348782e76dca1867115976915a197b0a1fd2", size = 3254935, upload-time = "2025-11-26T20:54:52.458Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" From dd9eda63bfdfbf0db387f188cf2fc1bffce05cd3 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sat, 13 Dec 2025 22:29:17 -0600 Subject: [PATCH 41/97] ci: pin actions to specific commits --- .github/workflows/build.yml | 22 ++++++++++++---------- .github/workflows/status_checks.yml | 8 +++++--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 941a66cb..bf60edad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,6 +1,6 @@ name: Build -permissions: { } +permissions: {} on: push: @@ -30,7 +30,9 @@ jobs: artifact_rename: OneLauncher-Windows.msi steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 v6.0.1 + with: + persist-credentials: false # `run_patch_client` C code - name: Install mingw-w64 on Linux @@ -44,7 +46,7 @@ jobs: run: make -C src/run_patch_client - name: Install mingw-w64 on Windows if: runner.os == 'Windows' - uses: msys2/setup-msys2@v2 + uses: msys2/setup-msys2@4f806de0a5a7294ffabaff804b38a9b435a73bda # v2.30.0 with: msystem: MINGW32 install: mingw-w64-i686-toolchain make @@ -57,11 +59,11 @@ jobs: # Can't use uv python with Nuitka yet # See https://github.com/Nuitka/Nuitka/issues/3331 - name: Install Python - uses: actions/setup-python@v6 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version-file: "pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 with: activate-environment: true - run: uv sync --locked --no-dev --group build @@ -80,7 +82,7 @@ jobs: echo "PYTHON_VERSION=$(python --version | awk '{print $2}' | cut -d '.' -f 1,2)" >> $GITHUB_ENV - name: Cache Nuitka cache directory if: ${{ !inputs.disable-cache }} - uses: actions/cache@v4 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ${{ env.NUITKA_CACHE_DIR }} key: nuitka-caching-${{ runner.os }}-${{ runner.arch }}-python-${{ env.PYTHON_VERSION }}-nuitka-${{ github.sha }} @@ -91,7 +93,7 @@ jobs: - name: Setup dotnet for building Windows installer if: runner.os == 'Windows' - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 with: dotnet-version: 8.0.x @@ -103,7 +105,7 @@ jobs: - name: Rename artifact run: mv build/out/${{ matrix.artifact_path_name }} build/out/${{ matrix.artifact_rename }} - name: Upload build artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ matrix.artifact_rename }} path: build/out/${{ matrix.artifact_rename }} @@ -118,11 +120,11 @@ jobs: contents: write # For making the release. steps: - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: path: build_artifacts/ - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0 with: draft: true fail_on_unmatched_files: true diff --git a/.github/workflows/status_checks.yml b/.github/workflows/status_checks.yml index 44aaa6bd..3696ad21 100644 --- a/.github/workflows/status_checks.yml +++ b/.github/workflows/status_checks.yml @@ -14,14 +14,16 @@ jobs: os: [ubuntu-latest, macos-latest, macos-15-intel, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false - name: Install Python - uses: actions/setup-python@v6 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version-file: "pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 with: activate-environment: true - run: uv sync --locked From 39dece6f1254ed27697042156d725203a0e5e40c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 14 Dec 2025 14:44:15 -0600 Subject: [PATCH 42/97] fix(wine): replace reference to `Wine` with `WINE` --- src/onelauncher/wine_environment.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 64a908a3..4d3f6273 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -163,7 +163,7 @@ def wine_setup(self) -> None: if self.wine_binary_path.exists(): return - self.dlgDownloader.setLabelText("Downloading Wine...") + self.dlgDownloader.setLabelText("Downloading WINE...") with TemporaryDirectory() as temp_dir_name: download_path = Path(temp_dir_name) / "wine.tar.xz" @@ -172,7 +172,7 @@ def wine_setup(self) -> None: return self.dlgDownloader.reset() - self.dlgDownloader.setLabelText("Extracting Wine...") + self.dlgDownloader.setLabelText("Extracting WINE...") self.dlgDownloader.setValue(99) self._wine_extractor(download_path) self.dlgDownloader.setValue(100) From 918ea1c966ebae62d8a5b32c2898b2987a65e2d5 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 14 Dec 2025 14:46:40 -0600 Subject: [PATCH 43/97] feat(wine): use sikarugir WINE on macOS --- src/onelauncher/wine_environment.py | 99 +++++++++++++++-------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 4d3f6273..6f7136ff 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -54,22 +54,24 @@ logger = logging.getLogger(__name__) -MOLTENVK_VERSION = "1.2.10-cxp20241028-UE4hack-zeroinit" # spellchecker:disable-line -MOLTENVK_URL = "https://github.com/Sikarugir-App/MoltenVK/releases/download/v1.2.10/macos-1.2.10-cxp20241028-UE4hack-zeroinit.tar.xz" if sys.platform == "darwin": - WINE_VERSION = "staging-10.20" - WINE_URL = "https://github.com/Gcenx/macOS_Wine_builds/releases/download/10.20/wine-staging-10.20-osx64.tar.xz" - DXVK_VERSION = "1.10.3-20230507-repack" - DXVK_URL = "https://github.com/Gcenx/DXVK-macOS/releases/download/v1.10.3-20230507-repack/dxvk-macOS-async-v1.10.3-20230507-repack.tar.gz" + WINE_VERSION = "WS12WineSikarugir10.0_2" + WINE_URL = "https://github.com/Sikarugir-App/Engines/releases/download/v1.0/WS12WineSikarugir10.0_2.tar.xz" + else: # To use Proton, replace link with Proton build and uncomment # `self.proton_documents_symlinker()` in wine_setup in wine_management WINE_VERSION = "10.20-staging-tkg-amd64-wow64" WINE_URL = "https://github.com/Kron4ek/Wine-Builds/releases/download/10.20/wine-10.20-staging-tkg-amd64-wow64.tar.xz" - DXVK_VERSION = "2.7.1" - DXVK_URL = ( - "https://github.com/doitsujin/dxvk/releases/download/v2.7.1/dxvk-2.7.1.tar.gz" - ) + +DXVK_VERSION = "2.7.1" +DXVK_URL = ( + "https://github.com/doitsujin/dxvk/releases/download/v2.7.1/dxvk-2.7.1.tar.gz" +) + +# macOS only. Includes DXVK. +SIKARUGIR_FRAMEWORKS_VERSION = "Template-1.0.5" +SIKARUGIR_FRAMEWORKS_URL = "https://github.com/Sikarugir-App/Wrapper/releases/download/v1.0/Template-1.0.5.tar.xz" @attrs.define @@ -97,11 +99,12 @@ def __init__(self) -> None: self.downloads_path / f"wine-{WINE_VERSION}" ) self.wine_binary_path: Final[Path] = self.latest_wine_path / "bin" / "wine" + self.latest_dxvk_path: Final[Path] = ( self.downloads_path / f"dxvk-{DXVK_VERSION}" ) - self.latest_moltenvk_path: Final[Path] = ( - self.downloads_path / f"moltenvk-{MOLTENVK_VERSION}" + self.latest_sikarugir_frameworks_path: Final[Path] = ( + self.downloads_path / f"frameworks-{SIKARUGIR_FRAMEWORKS_VERSION}" ) self._dlgDownloader: QtWidgets.QProgressDialog | None = None @@ -210,8 +213,6 @@ def _wine_extractor(self, archive_path: Path) -> None: tar.extractall(temp_dir, filter="data") source_dir = next(temp_dir.glob("*/")) - if sys.platform == "darwin": - source_dir = source_dir / "Contents" / "Resources" / "wine" # Using `shutil.move` instead of `Path.rename`, so that it works across # filesystems. move(source_dir, self.latest_wine_path) @@ -276,28 +277,25 @@ def _dxvk_injector(self) -> None: (self.prefix_system32 / dll).symlink_to(self.latest_dxvk_path / "x64" / dll) (self.prefix_syswow64 / dll).symlink_to(self.latest_dxvk_path / "x32" / dll) - def moltenvk_setup(self) -> None: - if self.latest_moltenvk_path.exists(): - self._moltenvk_injector() + def sikarugir_frameworks_setup(self) -> None: + if self.latest_sikarugir_frameworks_path.exists(): return - self.dlgDownloader.setLabelText("Downloading MoltenVK...") + self.dlgDownloader.setLabelText("Downloading WINE dependencies...") with TemporaryDirectory() as temp_dir_name: - download_path = Path(temp_dir_name) / "moltenvk.tar.xz" + download_path = Path(temp_dir_name) / "sikarugir_frameworks.tar.xz" - if not self._downloader(MOLTENVK_URL, download_path): + if not self._downloader(SIKARUGIR_FRAMEWORKS_URL, download_path): return self.dlgDownloader.reset() - self.dlgDownloader.setLabelText("Extracting MoltenVK...") + self.dlgDownloader.setLabelText("Extracting WINE dependencies...") self.dlgDownloader.setValue(99) - self._moltenvk_extractor(download_path) + self._sikarugir_frameworks_extractor(download_path) self.dlgDownloader.setValue(100) - self._moltenvk_injector() - - def _moltenvk_extractor(self, archive_path: Path) -> None: + def _sikarugir_frameworks_extractor(self, archive_path: Path) -> None: with TemporaryDirectory() as temp_dir_name: temp_dir = Path(temp_dir_name) @@ -305,26 +303,19 @@ def _moltenvk_extractor(self, archive_path: Path) -> None: with lzma.open(archive_path) as file, tarfile.open(fileobj=file) as tar: tar.extractall(temp_dir, filter="data") - source_dir = ( - next(temp_dir.glob("*/")) / "Release" / "MoltenVK" / "dylib" / "macOS" - ) + source_dir = next(temp_dir.glob("*/")) / "Contents" / "Frameworks" # Using `shutil.move` instead of `Path.rename`, so that it works across # filesystems. - move(source_dir, self.latest_moltenvk_path) + move(source_dir, self.latest_sikarugir_frameworks_path) - # Remove old MoltenVK versions. + # Remove old versions. for folder in self.downloads_path.glob("*/"): if ( - folder.name.startswith("moltenvk") - and folder != self.latest_moltenvk_path + folder.name.startswith("frameworks") + and folder != self.latest_sikarugir_frameworks_path ): rmtree(folder) - def _moltenvk_injector(self) -> None: - wine_moltenvk = self.latest_wine_path / "lib" / "libMoltenVK.dylib" - wine_moltenvk.unlink(missing_ok=True) - wine_moltenvk.symlink_to(self.latest_moltenvk_path / "libMoltenVK.dylib") - def setup_files(self) -> None: self.downloads_path.mkdir(parents=True, exist_ok=True) self.prefix_system32.mkdir(parents=True, exist_ok=True) @@ -332,11 +323,10 @@ def setup_files(self) -> None: self.wine_setup() self.dlgDownloader.reset() - self.dxvk_setup() if sys.platform == "darwin": - self.dlgDownloader.reset() - # Must be after WINE setup, b/c it edits WINE files. - self.moltenvk_setup() + self.sikarugir_frameworks_setup() + else: + self.dxvk_setup() self.dlgDownloader.close() self.is_setup = True @@ -371,11 +361,8 @@ def get_wine_process_args( # Disable mscoree and mshtml to avoid downloading wine mono and gecko. wine_dll_overrides: list[str] = ["mscoree=d", "mshtml=d"] # Add dll overrides for DirectX, so DXVK is used instead of wine3d. - wine_dll_overrides.extend( - ("d3d11=n", "d3d10core=n") - if sys.platform == "darwin" - else ("d3d11=n", "dxgi=n", "d3d10core=n", "d3d9=n") - ) + if sys.platform != "darwin": + wine_dll_overrides.extend(("d3d11=n", "dxgi=n", "d3d10core=n", "d3d9=n")) edited_environment["WINEDLLOVERRIDES"] = ";".join(wine_dll_overrides) if sys.platform != "darwin": @@ -390,6 +377,26 @@ def get_wine_process_args( edited_environment["WINEFSYNC"] = "1" if sys.platform == "darwin": + edited_environment["DYLD_FALLBACK_LIBRARY_PATH"] = ":".join( + str(path) + for path in ( + (wine_management.latest_sikarugir_frameworks_path / "moltenvkcx"), + (wine_management.latest_wine_path / "lib"), + (wine_management.latest_wine_path / "lib64"), + wine_management.latest_sikarugir_frameworks_path, + Path("/opt/wine/lib"), + Path("/usr/lib"), + Path("/usr/libexec"), + Path("/usr/lib/system"), + ) + ) + edited_environment["WINEDLLPATH_PREPEND"] = str( + wine_management.latest_sikarugir_frameworks_path + / "renderer" + / "dxvk" + / "wine" + ) + # "wine doesn't handle VK_ERROR_DEVICE_LOST correctly" # -- edited_environment["MVK_CONFIG_RESUME_LOST_DEVICE"] = "1" From 35759468b7a929bcf1b88ee465670c500411ca17 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 14 Dec 2025 18:28:46 -0600 Subject: [PATCH 44/97] feat(patch_game): log patching phase --- src/onelauncher/patch_game.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index fe65abf6..2f52f178 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -177,8 +177,9 @@ async def patch_game( ) try: async with trio.open_nursery() as nursery: - for phase in patch_phases: + for i, phase in enumerate(patch_phases): progress_monitor.reset() + logger.info("Phase %s/%s", i + 1, len(patch_phases)) process: trio.Process = await nursery.start( partial( trio.run_process, From a6ecc0f24cd02dc9f694964af046ae50478cd9ba Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 14 Dec 2025 19:40:23 -0600 Subject: [PATCH 45/97] refactor: use attrs for `GameLauncherConfig` --- .../network/game_launcher_config.py | 184 +++++------------- 1 file changed, 54 insertions(+), 130 deletions(-) diff --git a/src/onelauncher/network/game_launcher_config.py b/src/onelauncher/network/game_launcher_config.py index 9e082622..ad7e8b5a 100644 --- a/src/onelauncher/network/game_launcher_config.py +++ b/src/onelauncher/network/game_launcher_config.py @@ -1,6 +1,8 @@ import logging +from types import MappingProxyType from typing import Self +import attrs from asyncache import cached from cachetools import TTLCache from httpx import HTTPError @@ -22,72 +24,34 @@ class GameLauncherConfigParseError(KeyError): """Config doesn't match expected game launcher config format""" -class NoGameClientFilenameError(Exception): - """No game client filenames for any supported client type provided""" - - +@attrs.frozen class GameLauncherConfig: - def __init__( - self, - client_win64_filename: str | None, - client_win32_filename: str | None, - client_win32_legacy_filename: str | None, - client_launch_args_template: str, - client_crash_server_arg: str | None, - client_auth_server_arg: str | None, - client_gls_ticket_lifetime_arg: str | None, - client_default_upload_throttle_mbps_arg: str | None, - client_bug_url_arg: str | None, - client_support_url_arg: str | None, - client_support_service_url_arg: str | None, - high_res_patch_arg: str | None, - patching_product_code: str, - login_queue_url: str, - login_queue_params_template: str, - newsfeed_url_template: str, - ) -> None: - """ - Raises: - NoGameClientFilenameError: All game client filenames are None. - - """ - self._client_win64_filename = client_win64_filename - self._client_win32_filename = client_win32_filename - self._client_win32_legacy_filename = client_win32_legacy_filename - self._client_launch_args_template = client_launch_args_template - self._client_crash_server_arg = client_crash_server_arg - self._client_auth_server_arg = client_auth_server_arg - self._client_gls_ticket_lifetime_arg = client_gls_ticket_lifetime_arg - self._client_default_upload_throttle_mbps_arg = ( - client_default_upload_throttle_mbps_arg - ) - self._client_bug_url_arg = client_bug_url_arg - self._client_support_url_arg = client_support_url_arg - self._client_support_service_url_arg = client_support_service_url_arg - self._high_res_patch_arg = high_res_patch_arg - self._patching_product_code = patching_product_code - self._login_queue_url = login_queue_url - self._login_queue_params_template = login_queue_params_template - if newsfeed_url_template == DDO_PREVIEW_BROKEN_NEWS_URL_TEMPLATE: - # Fix broken DDO Preview server newsfeed URL - self._newsfeed_url_template = DDO_PREVIEW_NEWS_URL_TEMPLATE - else: - self._newsfeed_url_template = newsfeed_url_template - - # Dictionary is ordered according to client type similarity from a user - # perspective. See unit tests for `self.get_client_filename` - self.client_type_mapping = { - ClientType.WIN64: self._client_win64_filename, - ClientType.WIN32: self._client_win32_filename, - ClientType.WIN32_LEGACY: self._client_win32_legacy_filename, - } - - # Raise error if all client filenames in `self.client_type_mapping` are - # `None` - if not [val for val in self.client_type_mapping.values() if val is not None]: - raise NoGameClientFilenameError( - "All client filenames are `None`. At least one must be provided." - ) + _client_win64_filename: str | None + _client_win32_filename: str | None + _client_win32_legacy_filename: str | None + client_launch_args_template: str + client_crash_server_arg: str | None + client_auth_server_arg: str | None + """Auth server URL for refreshing the GLS ticket.""" + client_gls_ticket_lifetime_arg: str | None + """The lifetime of GLS tickets.""" + client_default_upload_throttle_mbps_arg: str | None + client_bug_url_arg: str | None + """The url that should be used for reporting bugs.""" + client_support_url_arg: str | None + """URL that should be used for in game support.""" + client_support_service_url_arg: str | None + """URL that should be used for auto submission of in game support tickets.""" + high_res_patch_arg: str | None + """ + Argument used to tell the client that the high resolution + texture dat file was not updated. This will cause + the client to not switch into high-res textures mode. + """ + patching_product_code: str + login_queue_url: str + login_queue_params_template: str + _newsfeed_url_template: str @classmethod def from_xml(cls: type[Self], appsettings_config_xml: str) -> Self: @@ -112,7 +76,12 @@ def from_xml(cls: type[Self], appsettings_config_xml: str) -> Self: ): # In past game versions, there was only one client. It was # accessed with "GameClient.Filename" - client_win32_legacy_filename = config_dict["GameClient.Filename"] + client_win32_legacy_filename = config_dict.get("GameClient.Filename") + + if not client_win32_legacy_filename: + raise GameLauncherConfigParseError( + "Config doesn't include any client filenames of a supported client type" + ) if "GameClient.WIN32.ArgTemplate" in config_dict: arg_template = config_dict["GameClient.WIN32.ArgTemplate"] @@ -147,10 +116,6 @@ def from_xml(cls: type[Self], appsettings_config_xml: str) -> Self: raise GameLauncherConfigParseError( "Config doesn't include a required value" ) from e - except NoGameClientFilenameError as e: - raise GameLauncherConfigParseError( - "Config doesn't include any client filenames of a supported client type" - ) from e @classmethod @cached(cache=TTLCache(maxsize=48, ttl=60 * 2)) @@ -191,6 +156,18 @@ def get_specific_client_filename(self, client_type: ClientType) -> str | None: """Return filename or None if unavailable, for client of type `client_type`""" return self.client_type_mapping[client_type] + @property + def client_type_mapping(self) -> MappingProxyType[ClientType, str | None]: + # Dictionary is ordered according to client type similarity from a user + # perspective. See unit tests for `self.get_client_filename` + return MappingProxyType( + { + ClientType.WIN64: self._client_win64_filename, + ClientType.WIN32: self._client_win32_filename, + ClientType.WIN32_LEGACY: self._client_win32_legacy_filename, + } + ) + def get_client_filename( self, preferred_client_type: ClientType | None = None ) -> tuple[str, ClientType]: @@ -233,64 +210,11 @@ def get_client_filename( return client_filename, new_client_type or preferred_client_type - @property - def client_launch_args_template(self) -> str: - return self._client_launch_args_template - - @property - def client_crash_server_arg(self) -> str | None: - return self._client_crash_server_arg - - @property - def client_auth_server_arg(self) -> str | None: - """Auth server URL for refreshing the GLS ticket.""" - return self._client_auth_server_arg - - @property - def client_gls_ticket_lifetime_arg(self) -> str | None: - """The lifetime of GLS tickets.""" - return self._client_gls_ticket_lifetime_arg - - @property - def client_default_upload_throttle_mbps_arg(self) -> str | None: - return self._client_default_upload_throttle_mbps_arg - - @property - def client_bug_url_arg(self) -> str | None: - """The url that should be used for reporting bugs.""" - return self._client_bug_url_arg - - @property - def client_support_url_arg(self) -> str | None: - """URL that should be used for in game support.""" - return self._client_support_url_arg - - @property - def client_support_service_url_arg(self) -> str | None: - """URL that should be used for auto submission of in game support tickets.""" - return self._client_support_service_url_arg - - @property - def high_res_patch_arg(self) -> str | None: - """ - Argument used to tell the client that the high resolution - texture dat file was not updated. This will cause - the client to not switch into high-res textures mode.""" - return self._high_res_patch_arg - - @property - def patching_product_code(self) -> str: - return self._patching_product_code - - @property - def login_queue_url(self) -> str: - return self._login_queue_url - - @property - def login_queue_params_template(self) -> str: - return self._login_queue_params_template - def get_newfeed_url(self, locale: OneLauncherLocale) -> str: - return self._newsfeed_url_template.replace( - "{lang}", locale.lang_tag.split("-")[0] - ) + if self._newsfeed_url_template == DDO_PREVIEW_BROKEN_NEWS_URL_TEMPLATE: + # Fix broken DDO Preview server newsfeed URL. + newsfeed_url_template = DDO_PREVIEW_NEWS_URL_TEMPLATE + else: + newsfeed_url_template = self._newsfeed_url_template + + return newsfeed_url_template.replace("{lang}", locale.lang_tag.split("-")[0]) From 788875e1518cb3788577a39b751c25bd0d4a52e7 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 17 Dec 2025 11:13:14 -0600 Subject: [PATCH 46/97] refactor: use tuples for official domain constants --- src/onelauncher/official_clients.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/onelauncher/official_clients.py b/src/onelauncher/official_clients.py index 6411a4fc..4b0b434b 100644 --- a/src/onelauncher/official_clients.py +++ b/src/onelauncher/official_clients.py @@ -41,31 +41,31 @@ logger = logging.getLogger(__name__) LOTRO_GLS_PREVIEW_DOMAIN = "gls-bullroarer.lotro.com" -LOTRO_GLS_DOMAINS: Final = [ +LOTRO_GLS_DOMAINS: Final = ( "gls.lotro.com", "gls-auth.lotro.com", # Same as gls.lotro.com LOTRO_GLS_PREVIEW_DOMAIN, -] +) # Same as main gls domain, but ssl certificate isn't valid for this domain. LOTRO_GLS_INVALID_SSL_DOMAIN: Final = "moria.gls.lotro.com" DDO_GLS_PREVIEW_DOMAIN: Final = "gls-lm.ddo.com" DDO_GLS_PREVIEW_IP: Final = "198.252.160.33" -DDO_GLS_DOMAINS: Final = [ +DDO_GLS_DOMAINS: Final = ( "gls.ddo.com", "gls-auth.ddo.com", # Same as gls.ddo.com DDO_GLS_PREVIEW_DOMAIN, -] +) # Forums where RSS feeds used as newsfeeds are -LOTRO_FORMS_DOMAINS: Final = [ +LOTRO_FORMS_DOMAINS: Final = ( "forums.lotro.com", "forums-old.lotro.com", -] -DDO_FORMS_DOMAINS: Final = [ +) +DDO_FORMS_DOMAINS: Final = ( "forums.ddo.com", "forums-old.ddo.com", -] +) # DDO preview client provides broken news URL template. This info is used # to fix it. DDO_PREVIEW_BROKEN_NEWS_URL_TEMPLATE: Final = ( @@ -90,7 +90,7 @@ def is_official_game_server(url: str) -> bool: + LOTRO_FORMS_DOMAINS + DDO_GLS_DOMAINS + DDO_FORMS_DOMAINS - + [LOTRO_GLS_INVALID_SSL_DOMAIN, DDO_GLS_PREVIEW_IP] + + (LOTRO_GLS_INVALID_SSL_DOMAIN, DDO_GLS_PREVIEW_IP) ) From b47c6e5a3297d37461317826014202fb73918719 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 17 Dec 2025 17:16:15 -0600 Subject: [PATCH 47/97] feat: support initial data and splashscreen patching --- src/onelauncher/main_window.py | 10 +- src/onelauncher/network/akamai.py | 96 ++++++ .../network/game_launcher_config.py | 53 ++- .../schemas/akamai_patching_file_list.xsd | 25 ++ .../schemas/splashscreen_file_list.xsd | 18 + src/onelauncher/patch_game.py | 312 ++++++++++++++++-- src/onelauncher/patch_game_window.py | 25 +- .../network/test_game_launcher_config.py | 3 + 8 files changed, 484 insertions(+), 58 deletions(-) create mode 100644 src/onelauncher/network/akamai.py create mode 100644 src/onelauncher/network/schemas/akamai_patching_file_list.xsd create mode 100644 src/onelauncher/network/schemas/splashscreen_file_list.xsd diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 029a8fb8..f3ac80c3 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -818,14 +818,10 @@ def setup_game(self) -> bool: if not ( game_config.game_directory / f"client_local_{locale.game_language_name}.dat" ).exists(): - logger.error( - "There is no game language data for %s installed. You may have to " - "select %s in the standard game launcher and wait for the data to download." - " The standard game launcher can be opened from the settings menu.", - locale.display_name, - locale.display_name, + logger.warning( + "The game needs to be patched. That can be done from the dropdown " + "menu on the Play button." ) - return False return True diff --git a/src/onelauncher/network/akamai.py b/src/onelauncher/network/akamai.py new file mode 100644 index 00000000..31af8b88 --- /dev/null +++ b/src/onelauncher/network/akamai.py @@ -0,0 +1,96 @@ +from pathlib import Path +from typing import Any, Final, Literal, Self + +import attrs +import xmlschema +from asyncache import cached as async_cached +from cachetools import TTLCache + +from onelauncher.network.httpx_client import get_httpx_client +from onelauncher.resources import data_dir + +_PATCHING_FILE_LIST_SCHEMA: Final = xmlschema.XMLSchema( + source=data_dir / "network" / "schemas" / "akamai_patching_file_list.xsd" +) +_SPLASHSCREEN_FILE_LIST_SCHEMA: Final = xmlschema.XMLSchema( + data_dir / "network" / "schemas" / "splashscreen_file_list.xsd" +) + + +@attrs.frozen(kw_only=True) +class PatchingDownloadFile: + relative_url: str + relative_path: Path + size: int + """bytes""" + md5_hash: str + + +@attrs.frozen(kw_only=True) +class PatchingDownloadList: + download_files: tuple[PatchingDownloadFile, ...] + + @classmethod + @async_cached(cache=TTLCache(maxsize=1, ttl=60 * 5)) + async def get_from_url(cls: type[Self], url: str) -> Self: + """ + Raises: + HTTPError: Network error while downloading the file list + XMLSchemaValidationError: File list doesn't match schema + """ + response = await get_httpx_client(url).get(url) + response.raise_for_status() + + file_list_dict: dict[Literal["File"], Any] = _PATCHING_FILE_LIST_SCHEMA.to_dict( # type: ignore[assignment] + response.text + ) + return cls( + download_files=tuple( + PatchingDownloadFile( + relative_url=file_dict["From"].replace("\\", "/"), + relative_path=Path(file_dict["To"].replace("\\", "/")), + size=file_dict["Size"], + md5_hash=file_dict["MD5"], + ) + for file_dict in file_list_dict["File"] + ) + ) + + +@attrs.frozen(kw_only=True) +class SplashscreenDownloadFile: + url: str + description: str + relative_path: Path + + +@attrs.frozen(kw_only=True) +class SplashscreenDownloadList: + download_files: tuple[SplashscreenDownloadFile, ...] + + @classmethod + @async_cached(cache=TTLCache(maxsize=1, ttl=60 * 5)) + async def get_from_url(cls: type[Self], url: str) -> Self: + """ + Raises: + HTTPError: Network error while downloading the file list + XMLSchemaValidationError: File list doesn't match schema + """ + response = await get_httpx_client(url).get(url) + response.raise_for_status() + + file_list_dict: dict[Literal["File"], Any] = ( + _SPLASHSCREEN_FILE_LIST_SCHEMA.to_dict( # type: ignore[assignment] + response.text + ) + ) + return cls( + download_files=tuple( + SplashscreenDownloadFile( + url=file_dict["DownloadUrl"], + description=file_dict["Description"], + relative_path=Path(file_dict["FileName"].replace("\\", "/")), + ) + for file_dict in file_list_dict["File"] + ) + ) diff --git a/src/onelauncher/network/game_launcher_config.py b/src/onelauncher/network/game_launcher_config.py index ad7e8b5a..8d73a544 100644 --- a/src/onelauncher/network/game_launcher_config.py +++ b/src/onelauncher/network/game_launcher_config.py @@ -24,7 +24,7 @@ class GameLauncherConfigParseError(KeyError): """Config doesn't match expected game launcher config format""" -@attrs.frozen +@attrs.frozen(kw_only=True) class GameLauncherConfig: _client_win64_filename: str | None _client_win32_filename: str | None @@ -52,6 +52,13 @@ class GameLauncherConfig: login_queue_url: str login_queue_params_template: str _newsfeed_url_template: str + download_files_list_url: str | None + """ + XML List of files to download every launcher start. As far as I know, these are only + ever splashscreens. + """ + akamai_download_url: str | None + game_version: str | None @classmethod def from_xml(cls: type[Self], appsettings_config_xml: str) -> Self: @@ -91,22 +98,33 @@ def from_xml(cls: type[Self], appsettings_config_xml: str) -> Self: arg_template = config_dict["GameClient.ArgTemplate"] return cls( - client_win64_filename, - client_win32_filename, - client_win32_legacy_filename, - arg_template, - config_dict.get("GameClient.Arg.crashreceiver"), - config_dict.get("GameClient.Arg.authserverurl"), - config_dict.get("GameClient.Arg.glsticketlifetime"), - config_dict.get("GameClient.Arg.DefaultUploadThrottleMbps"), - config_dict.get("GameClient.Arg.bugurl"), - config_dict.get("GameClient.Arg.supporturl"), - config_dict.get("GameClient.Arg.supportserviceurl"), - config_dict.get("GameClient.HighResPatchArg"), - config_dict["Patching.ProductCode"], - config_dict["WorldQueue.LoginQueue.URL"], - config_dict["WorldQueue.TakeANumber.Parameters"], - config_dict["URL.NewsFeed"], + client_win64_filename=client_win64_filename, + client_win32_filename=client_win32_filename, + client_win32_legacy_filename=client_win32_legacy_filename, + client_launch_args_template=arg_template, + client_crash_server_arg=config_dict.get("GameClient.Arg.crashreceiver"), + client_auth_server_arg=config_dict.get("GameClient.Arg.authserverurl"), + client_gls_ticket_lifetime_arg=config_dict.get( + "GameClient.Arg.glsticketlifetime" + ), + client_default_upload_throttle_mbps_arg=config_dict.get( + "GameClient.Arg.DefaultUploadThrottleMbps" + ), + client_bug_url_arg=config_dict.get("GameClient.Arg.bugurl"), + client_support_url_arg=config_dict.get("GameClient.Arg.supporturl"), + client_support_service_url_arg=config_dict.get( + "GameClient.Arg.supportserviceurl" + ), + high_res_patch_arg=config_dict.get("GameClient.HighResPatchArg"), + patching_product_code=config_dict["Patching.ProductCode"], + login_queue_url=config_dict["WorldQueue.LoginQueue.URL"], + login_queue_params_template=config_dict[ + "WorldQueue.TakeANumber.Parameters" + ], + newsfeed_url_template=config_dict["URL.NewsFeed"], + download_files_list_url=config_dict.get("URL.DownloadFilesList"), + akamai_download_url=config_dict.get("URL.AkamaiDownloadURL"), + game_version=config_dict.get("Game.Version"), ) except AppSettingsParseError as e: raise GameLauncherConfigParseError( @@ -139,6 +157,7 @@ async def from_game_config(cls: type[Self], game_config: GameConfig) -> Self | N return None return await cls.from_url(game_services_info.launcher_config_url) except (HTTPError, GameLauncherConfigParseError): + logger.exception("Loading `GameLauncherConfig` from `GameConfig` failed") return None @staticmethod diff --git a/src/onelauncher/network/schemas/akamai_patching_file_list.xsd b/src/onelauncher/network/schemas/akamai_patching_file_list.xsd new file mode 100644 index 00000000..c627556f --- /dev/null +++ b/src/onelauncher/network/schemas/akamai_patching_file_list.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/onelauncher/network/schemas/splashscreen_file_list.xsd b/src/onelauncher/network/schemas/splashscreen_file_list.xsd new file mode 100644 index 00000000..68bcae00 --- /dev/null +++ b/src/onelauncher/network/schemas/splashscreen_file_list.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index 2f52f178..5b13b7a5 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -2,18 +2,32 @@ import os import subprocess from functools import partial +from math import log, trunc from pathlib import Path from types import MappingProxyType from typing import Literal, TypeAlias, assert_never +from uuid import uuid4 import attrs +import httpx import trio +from httpx import HTTPError +from xmlschema import XMLSchemaValidationError from onelauncher.async_utils import for_each_in_stream from onelauncher.config_manager import ConfigManager from onelauncher.game_config import GameConfigID from onelauncher.logs import ExternalProcessLogsFilter +from onelauncher.network.akamai import ( + PatchingDownloadFile, + PatchingDownloadList, + SplashscreenDownloadFile, + SplashscreenDownloadList, +) +from onelauncher.network.game_launcher_config import GameLauncherConfig +from onelauncher.network.httpx_client import get_httpx_client from onelauncher.resources import data_dir +from onelauncher.utilities import CaseInsensitiveAbsolutePath from onelauncher.wine_environment import get_wine_process_args logger = logging.getLogger(__name__) @@ -21,6 +35,14 @@ PatchPhase: TypeAlias = Literal["FullPatch", "FilesOnly", "DataOnly"] +# Run file patching twice to avoid problems when patchclient.dll +# self-patches. +PATCHCLIENT_PATCH_PHASES: tuple[PatchPhase, ...] = ( + "FilesOnly", + "FilesOnly", + "DataOnly", +) + PATCH_CLIENT_RUNNER = data_dir.parent / "run_patch_client" / "run_ptch_client.exe" """ Executable used to run `patchclient.dll` and get output from it. This is done with a @@ -29,18 +51,89 @@ """ +@attrs.define(eq=False) +class ProgressItem: + completed: int = 0 + total: int = 0 + + @attrs.frozen -class PatchingProgress: - total_iterations: int - current_iterations: int +class CurrentProgress: + completed: int + total: int + progress_text: str + + +@attrs.define +class Progress: + progress_items: list[ProgressItem] = attrs.Factory(list) + unit_type: Literal["byte"] | None = None + progress_text_suffix: str = "" + + def reset(self) -> None: + self.progress_items = [] + self.unit_type = None + self.progress_text_suffix = "" + + def _pick_unit_and_suffix( + self, size: int, suffixes: tuple[str, ...], base: int + ) -> tuple[int, str]: + if not suffixes: + return 1, "" + + ideal_exponent = trunc(log(size, base)) + exponent = min(ideal_exponent, len(suffixes) - 1) + return base**exponent, suffixes[exponent] + + def get_current_progress(self) -> CurrentProgress: + sum_completed = 0 + sum_total = 0 + for progress_item in self.progress_items: + sum_completed += progress_item.completed + sum_total += progress_item.total + + # Don't want >100%. + sum_completed = min(sum_completed, sum_total) + + if sum_total == 0: + return CurrentProgress( + completed=0, total=0, progress_text=self.progress_text_suffix + ) + + if self.unit_type is None: + unit, suffix = 1, "" + elif self.unit_type == "byte": + unit, suffix = self._pick_unit_and_suffix( + size=sum_total, + suffixes=("bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), + base=1000, + ) + else: + assert_never() + precision = 0 if unit == 1 else 1 + completed_str = f"{sum_completed / unit:,.{precision}f}" + total_str = f"{sum_total / unit:,.{precision}f}" + progress_text = f"{sum_completed / sum_total:.0%} ({completed_str}/{total_str} {suffix}){self.progress_text_suffix}" + + return CurrentProgress( + # Using 0 to 10,000 instead of 0 to `current_progress.total` to prevent + # overfloq errors. + completed=round(sum_completed / sum_total * 10000), + total=10000, + progress_text=progress_text, + ) class PatchingProgressMonitor: - def __init__(self) -> None: + def __init__(self, progress: Progress) -> None: + self.progress = progress self.reset() def reset(self) -> None: self.patching_type = None + self.progress.reset() + self.progress_item = ProgressItem() + self.progress.progress_items.append(self.progress_item) @property def patching_type(self) -> Literal["file", "data"] | None: @@ -53,22 +146,22 @@ def patching_type(self, patching_type: Literal["file", "data"] | None) -> None: self.current_iterations: int = 0 self.applying_forward_iterations: bool = False - def get_patching_progress(self) -> PatchingProgress: - return PatchingProgress( - total_iterations=self.total_iterations, - current_iterations=self.current_iterations, - ) + def _update_progress(self) -> None: + self.progress_item.total = self.total_iterations + self.progress_item.completed = self.current_iterations - def feed_line(self, line: str) -> PatchingProgress: + def feed_line(self, line: str) -> None: cleaned_line = line.strip().lower() # Beginning of a patching type if cleaned_line.startswith("checking files"): self.patching_type = "file" - return self.get_patching_progress() + self._update_progress() + return elif cleaned_line.startswith("checking data"): self.patching_type = "data" - return self.get_patching_progress() + self._update_progress() + return # Right after a patching type begins. Find out how many iterations there will be. if cleaned_line.startswith("files to patch:"): self.total_iterations = int( @@ -98,7 +191,7 @@ def feed_line(self, line: str) -> PatchingProgress: elif self.applying_forward_iterations and "." in cleaned_line: self.current_iterations += len(cleaned_line.split(".")) - return self.get_patching_progress() + self._update_progress() def get_patchclient_arguments( @@ -133,9 +226,172 @@ def get_patchclient_arguments( return (*base_arguments, phase_arg) +async def _handle_akamai_download_file( + download_file: PatchingDownloadFile | SplashscreenDownloadFile, + game_directory: CaseInsensitiveAbsolutePath, + temp_download_dir: Path, + base_download_url: str, + progress: Progress, +) -> None: + """ + Always download `SplashscreenDownloadFile`. There is no hash to check on these. + + Download `PatchingDownloadFile` if it doesn't exist. The hash is not checked, + because the file may be out of date. These files are only meant for the initial large + download. Afterwards, `patchclient.dll` is used. + """ + local_path = trio.Path(game_directory / download_file.relative_path) + if isinstance(download_file, PatchingDownloadFile) and await local_path.exists(): + return + + logger.debug("Downloading %s", download_file) + + url = ( + f"{base_download_url}/{download_file.relative_url}" + if isinstance(download_file, PatchingDownloadFile) + else download_file.url + ) + temp_download_path = trio.Path( + temp_download_dir / f"{download_file.relative_path.name}-{uuid4()}" + ) + + progress_item = ProgressItem() + progress.progress_items.append(progress_item) + if isinstance(download_file, PatchingDownloadFile): + # Do before the web request, since it may take a while for a spot to open up + # in the connection pool and the web request to go through. + progress_item.total = download_file.size + + try: + async with ( + get_httpx_client(url).stream( + "GET", url, timeout=httpx.Timeout(6, pool=None) + ) as response, + await temp_download_path.open("wb") as temp_download_file, + ): + response.raise_for_status() + + bytes_currently_downloaded = response.num_bytes_downloaded + if isinstance(download_file, SplashscreenDownloadFile): + progress_item.total = int( + response.headers.get("Content-Length", 300000) + ) + + async for chunk in response.aiter_bytes(): + if isinstance(download_file, SplashscreenDownloadFile): + progress_item.completed += ( + response.num_bytes_downloaded - bytes_currently_downloaded + ) + bytes_currently_downloaded = response.num_bytes_downloaded + else: + progress_item.completed += len(chunk) + + await temp_download_file.write(chunk) + except HTTPError: + logger.warning("Failed to download %s", local_path.name, exc_info=True) + progress.progress_items.remove(progress_item) + else: + await temp_download_path.rename(local_path) + finally: + with trio.move_on_after(5, shield=True): + await temp_download_path.unlink(missing_ok=True) + + +@attrs.frozen(kw_only=True) +class AkamaiPatchingError(Exception): + msg: str + + +async def akamai_patching( + game_id: GameConfigID, config_manager: ConfigManager, progress: Progress +) -> None: + """ + Initial download of data after installation or switching languages and + splashscreen updates. Splashscreens are always updated. Only files that don't + exist for the initial data download are downloaded. + + Raises: + AkamaiPatchingFailed + """ + progress.unit_type = "byte" + + game_config = config_manager.get_game_config(game_id=game_id) + + game_launcher_config = await GameLauncherConfig.from_game_config(game_config) + if ( + not game_launcher_config + or not game_launcher_config.akamai_download_url + or not game_launcher_config.game_version + ): + raise AkamaiPatchingError(msg="Failed to load game launcher network config") + + file_list: tuple[PatchingDownloadFile | SplashscreenDownloadFile, ...] + + # Add patching download files to the file list. + language = ( + game_config.locale or config_manager.get_program_config().default_locale + ).lang_tag.split("-")[0] + # `akamai_download_url` ussed HTTP. The domain is a CNAME to an akamai subdomain. + # The certificate isn't valid for any of the domains involved, so this isn't being + # coerced to use HTTPS. + base_download_url = f"{game_launcher_config.akamai_download_url}/{game_launcher_config.game_version}" + download_list_url = ( + f"{base_download_url}/{language}_" + f"{'highres' if game_config.high_res_enabled else 'lowres'}_download_list.xml" + ) + try: + file_list = ( + await PatchingDownloadList.get_from_url(download_list_url) + ).download_files + except HTTPError as e: + raise AkamaiPatchingError( + msg="Network error while downloading patching file list" + ) from e + except XMLSchemaValidationError as e: + raise AkamaiPatchingError(msg="Error parsing patching file list") from e + + # Add splashscreens to file list. + if game_launcher_config.download_files_list_url: + try: + file_list = ( + file_list + + ( + await SplashscreenDownloadList.get_from_url( + game_launcher_config.download_files_list_url + ) + ).download_files + ) + except HTTPError: + logger.exception("Network error while downloading splashscreens file list") + except XMLSchemaValidationError: + logger.exception("Error parsing splashscreens file list") + else: + logger.error("Game launcher config is missing splashscreens update URL") + + # Directory where files will be downloaded before being moved to their final + # location. This is the same directory that the official launcher uses. A normal + # temp directory isn't used, because it might not be on the same filesystem. + # Downloading to the same filesystem is desirable, since these are large files. + temp_download_dir = game_config.game_directory / "downloading" + temp_download_dir.mkdir(exist_ok=True) + + async with trio.open_nursery() as nursery: + for download_file in file_list: + nursery.start_soon( + partial( + _handle_akamai_download_file, + download_file=download_file, + game_directory=game_config.game_directory, + temp_download_dir=temp_download_dir, + base_download_url=base_download_url, + progress=progress, + ) + ) + + async def patch_game( patch_server_url: str, - progress_monitor: PatchingProgressMonitor, + progress: Progress, game_id: GameConfigID, config_manager: ConfigManager, ) -> None: @@ -168,18 +424,26 @@ async def patch_game( command=command, environment=environment, wine_config=game_config.wine ) - # Run file patching twice to avoid problems when patchclient.dll - # self-patches. - patch_phases: tuple[PatchPhase, ...] = ( - "FilesOnly", - "FilesOnly", - "DataOnly", + # Initial data and splashscreens phase. + progress.progress_text_suffix = ( + f" Phase {1}/{len(PATCHCLIENT_PATCH_PHASES) + 1}" ) + try: + await akamai_patching( + game_id=game_id, config_manager=config_manager, progress=progress + ) + except AkamaiPatchingError as e: + logger.exception(e.msg) + logger.info("Skipping phase") + try: async with trio.open_nursery() as nursery: - for i, phase in enumerate(patch_phases): - progress_monitor.reset() - logger.info("Phase %s/%s", i + 1, len(patch_phases)) + patching_progress_monitor = PatchingProgressMonitor(progress=progress) + for i, phase in enumerate(PATCHCLIENT_PATCH_PHASES): + patching_progress_monitor.reset() + progress.progress_text_suffix = ( + f" Phase {i + 2}/{len(PATCHCLIENT_PATCH_PHASES) + 1}" + ) process: trio.Process = await nursery.start( partial( trio.run_process, @@ -213,7 +477,7 @@ async def patch_game( def process_output_line(line: str) -> None: process_logging_adapter.debug(line) # noqa: B023 - progress_monitor.feed_line(line) + patching_progress_monitor.feed_line(line) nursery.start_soon( partial(for_each_in_stream, process.stdout, process_output_line) diff --git a/src/onelauncher/patch_game_window.py b/src/onelauncher/patch_game_window.py index c9647073..26020bc4 100644 --- a/src/onelauncher/patch_game_window.py +++ b/src/onelauncher/patch_game_window.py @@ -39,7 +39,11 @@ from onelauncher.ui_utilities import log_record_to_rich_text from .config_manager import ConfigManager -from .patch_game import PATCH_CLIENT_RUNNER, PatchingProgressMonitor, patch_game +from .patch_game import ( + PATCH_CLIENT_RUNNER, + Progress, + patch_game, +) from .patch_game import logger as patch_game_logger from .ui.patching_window_uic import Ui_patchingDialog @@ -61,7 +65,7 @@ def __init__( self.config_manager = config_manager self.patch_server_url = patch_server_url - self.progress_monitor: PatchingProgressMonitor | None = None + self.progress: Progress | None = None self.ui = Ui_patchingDialog() self.ui.setupUi(self) @@ -114,7 +118,7 @@ def reset_buttons(self) -> None: self.ui.btnStop.setText("Close") self.ui.btnStart.setEnabled(True) - self.progress_monitor = None + self.progress = None # Make sure it's not showing a busy indicator self.ui.progressBar.setMinimum(1) self.ui.progressBar.setMaximum(1) @@ -130,26 +134,27 @@ def btnStopClicked(self) -> None: async def keep_progress_bar_updated(self) -> None: # Will be canceled once the patching window is closed. while True: - if self.progress_monitor: - progress = self.progress_monitor.get_patching_progress() - self.ui.progressBar.setMaximum(progress.total_iterations) - self.ui.progressBar.setValue(progress.current_iterations) - await trio.sleep(0.1) + if self.progress: + current_progress = self.progress.get_current_progress() + self.ui.progressBar.setFormat(current_progress.progress_text) + self.ui.progressBar.setMaximum(current_progress.total) + self.ui.progressBar.setValue(current_progress.completed) + await trio.sleep(0.05) async def start(self) -> None: self.patching_finished = False self.ui.btnStart.setEnabled(False) self.ui.btnStop.setText("Abort") - self.progress_monitor = PatchingProgressMonitor() + self.progress = Progress() logger.info("*** Started ***") with trio.CancelScope() as self.patching_cancel_scope: await patch_game( patch_server_url=self.patch_server_url, - progress_monitor=self.progress_monitor, game_id=self.game_id, config_manager=self.config_manager, + progress=self.progress, ) logger.info("*** Finished ***") diff --git a/tests/onelauncher/network/test_game_launcher_config.py b/tests/onelauncher/network/test_game_launcher_config.py index e572db42..c3a3438e 100644 --- a/tests/onelauncher/network/test_game_launcher_config.py +++ b/tests/onelauncher/network/test_game_launcher_config.py @@ -25,6 +25,9 @@ def get_mock_game_launcher_config_partial() -> partial[GameLauncherConfig]: login_queue_url="https://gls.lotro.com/GLS.AuthServer/LoginQueue.aspx", login_queue_params_template="command=TakeANumber&subscription={0}&ticket={1}&ticket_type=GLS&queue_url={2}", newsfeed_url_template="https://forums.lotro.com/{lang}/launcher-feed.xml", + download_files_list_url="http://akamai.lotro.com/lotro/patch/splashscreen/DownloadFilesList.xml", + akamai_download_url="http://installer.lotro.com/lotro/", + game_version="3601.0066.7272.4024", ) From 1b18cf27e72e88dfc5d6576d014331d6e86a728b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 21 Dec 2025 11:13:38 -0600 Subject: [PATCH 48/97] fix(setup_wizard): search for games non-blocking from start --- src/onelauncher/main.py | 6 ++- src/onelauncher/settings_window.py | 27 +++++++++---- src/onelauncher/setup_wizard.py | 62 +++++++++++++++++++----------- tests/onelauncher/test_cli.py | 14 ++++--- 4 files changed, 71 insertions(+), 38 deletions(-) diff --git a/src/onelauncher/main.py b/src/onelauncher/main.py index 969eb57a..9fcaf251 100644 --- a/src/onelauncher/main.py +++ b/src/onelauncher/main.py @@ -83,7 +83,8 @@ async def start_ui(config_manager: ConfigManager, game_id: GameConfigID | None) if not config_manager.program_config_path.exists(): logger.info("No program config found. Starting setup wizard.") setup_wizard = SetupWizard(config_manager) - if setup_wizard.exec() == QtWidgets.QDialog.DialogCode.Rejected: + await setup_wizard.run() + if setup_wizard.result() == QtWidgets.QDialog.DialogCode.Rejected: # Close program if the user left the setup wizard without finishing. return return await start_ui(config_manager=config_manager, game_id=game_id) @@ -98,7 +99,8 @@ async def start_ui(config_manager: ConfigManager, game_id: GameConfigID | None) f"No games have been registered with {__title__}.\n Opening games management wizard.", ) setup_wizard = SetupWizard(config_manager, game_selection_only=True) - if setup_wizard.exec() == QtWidgets.QDialog.DialogCode.Rejected: + await setup_wizard.run() + if setup_wizard.result() == QtWidgets.QDialog.DialogCode.Rejected: # Close program if the user left the setup wizard without finishing. return return await start_ui(config_manager=config_manager, game_id=game_id) diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 4ce21463..79fa6724 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -30,6 +30,7 @@ import re from contextlib import suppress from enum import StrEnum +from functools import partial from pathlib import Path from types import MappingProxyType @@ -83,8 +84,6 @@ def __init__(self, config_manager: ConfigManager, game_id: GameConfigID): self.ui.setupUi(self) def setup_ui(self) -> None: - self.finished.connect(self.cleanup) - self.tab_names = list(TabName) if os.name == "nt": self.tab_names.remove(TabName.WINE) @@ -183,9 +182,13 @@ def setup_ui(self) -> None: self.ui.gamesSortingModeComboBox.findData(program_config.games_sorting_mode) ) - self.ui.setupWizardButton.clicked.connect(self.start_setup_wizard) + self.ui.setupWizardButton.clicked.connect( + lambda: self.nursery.start_soon(self.start_setup_wizard) + ) self.ui.gamesManagementButton.clicked.connect( - lambda: self.start_setup_wizard(games_managing=True) + lambda: self.nursery.start_soon( + partial(self.start_setup_wizard, games_managing=True) + ) ) self.ui.gameDirButton.clicked.connect(self.choose_game_dir) self.ui.showAdvancedSettingsCheckbox.clicked.connect( @@ -205,6 +208,8 @@ def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: async def run(self) -> None: self.setup_ui() async with trio.open_nursery() as self.nursery: + self.finished.connect(self.cleanup) + self.nursery.start_soon(self.indicate_unavailable_client_types) self.nursery.start_soon(self.setup_newsfeed_option) self.nursery.start_soon(self.setup_game_settings_dir_option) @@ -414,8 +419,13 @@ def choose_game_settings_dir(self) -> None: return None self.ui.gameSettingsDirLineEdit.setText(str(folder)) - def start_setup_wizard(self, games_managing: bool = False) -> None: - self.hide() + async def start_setup_wizard(self, games_managing: bool = False) -> None: + visible_tope_level_widgets = tuple( + widget for widget in get_qapp().topLevelWidgets() if widget.isVisible() + ) + for widget in visible_tope_level_widgets: + widget.hide() + if games_managing: setup_wizard = SetupWizard( config_manager=self.config_manager, @@ -426,7 +436,10 @@ def start_setup_wizard(self, games_managing: bool = False) -> None: setup_wizard = SetupWizard( config_manager=self.config_manager, select_existing_games=False ) - setup_wizard.exec() + await setup_wizard.run() + + for widget in visible_tope_level_widgets: + widget.show() self.accept() def add_languages_to_combobox(self, combobox: QtWidgets.QComboBox) -> None: diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 9d71ee2a..fd1d4923 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -35,6 +35,7 @@ import attrs import qtawesome +import trio from PySide6 import QtCore, QtGui, QtWidgets from typing_extensions import override @@ -126,29 +127,28 @@ def __init__( self.ui = Ui_Wizard() self.ui.setupUi(self) self.setWindowTitle("Setup Wizard") + + self.migrate_old_config_asked: bool = False + + def setup_ui(self) -> None: # As of PySide 6.1, other styles don't have right spacing or work with the dark # theme on Windows. Sticking with this known look on all platforms for now. self.setWizardStyle(self.WizardStyle.ClassicStyle) - self.ui.gamesDiscoveryStatusLabel.hide() - - self.add_available_languages_to_ui() - color_scheme_changed = get_qapp().styleHints().colorSchemeChanged - self.migrate_old_config_asked: bool = False + # Language selection page + self.add_available_languages_to_ui() self.ui.languageSelectionWizardPage.validatePage = ( # type: ignore[method-assign] self.validateLanguageSelectionPage ) + # Games discovery page - self.ui.gamesSelectionWizardPage.initializePage = ( # type: ignore[method-assign] - self.initialize_games_selection_page - ) self.ui.gamesSelectionWizardPage.validatePage = self.validateGamesSelectionPage # type: ignore[method-assign] self.ui.addGameButton.clicked.connect(self.browse_for_game_dir) self.ui.upPriorityButton.clicked.connect(self.raise_selected_game_priority) self.ui.downPriorityButton.clicked.connect(self.lower_selected_game_priority) - self.games_found = False + # Existing game data page self.ui.dataDeletionWizardPage.setCommitPage(True) self.ui.gamesDeletionStatusListView.setModel(self.ui.gamesListWidget.model()) @@ -179,6 +179,7 @@ def data_deletion_page_update() -> None: color_scheme_changed.connect(data_deletion_page_update) if self.game_selection_only: self.ui.keepDataRadioButton.setChecked(True) + # Finished page self.accepted.connect(self.save_settings) @@ -186,10 +187,9 @@ def data_deletion_page_update() -> None: for page_id in self.pageIds(): self.removePage(page_id) self.addPage(self.ui.gamesSelectionWizardPage) - # Existing data page isn't needed, if there's no existing data + # Existing data page isn't needed, if there's no existing data. if self.config_manager.get_game_config_ids(): self.addPage(self.ui.dataDeletionWizardPage) - self.find_games() else: # Only show data deletion page if there is existing game data self.ui.gamesSelectionWizardPage.nextId = ( # type: ignore[method-assign] @@ -198,6 +198,26 @@ def data_deletion_page_update() -> None: else self.currentId() + 2 ) + self.open() + + async def run(self) -> None: + self.setup_ui() + async with trio.open_nursery() as self.nursery: + self.finished.connect(self.cleanup) + + self.nursery.start_soon(self.initialize_games_selection_page) + + # Will be canceled when the winddow is closed + self.nursery.start_soon(trio.sleep_forever) + + def cleanup(self) -> None: + self.nursery.cancel_scope.cancel() + + @override + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self.cleanup() + event.accept() + @override def changeEvent(self, event: QtCore.QEvent) -> None: super().changeEvent(event) @@ -312,16 +332,13 @@ def gamesDataButtonToggled( self.ui.gamesDeletionStatusListView.setEnabled(True) self.ui.dataDeletionWizardPage.completeChanged.emit() - def initialize_games_selection_page(self) -> None: - if not self.games_found: - - def find_games_and_hide_status_label() -> None: - self.find_games() - self.ui.gamesDiscoveryStatusLabel.hide() - - self.ui.gamesDiscoveryStatusLabel.setText("Finding game directories...") - self.ui.gamesDiscoveryStatusLabel.show() - QtCore.QTimer.singleShot(1, find_games_and_hide_status_label) + async def initialize_games_selection_page(self) -> None: + self.ui.gamesDiscoveryStatusLabel.setText( + "Searching for existing game directories..." + ) + self.ui.gamesDiscoveryStatusLabel.show() + await trio.to_thread.run_sync(self.find_games) + self.ui.gamesDiscoveryStatusLabel.hide() def find_games(self) -> None: self.add_existing_games() @@ -343,7 +360,7 @@ def find_games(self) -> None: (home_dir / ".steam/steamapps/compatdata", "*/"), ( home_dir / ".local/share/Steam/steamapps/compatdata/", - "*", + "*/", ), ( home_dir @@ -391,7 +408,6 @@ def sort_games(key: GameConfig) -> str: for game in sorted(self.found_games, key=sort_games): self.add_game(game_id=generate_game_config_id(game), game_config=game) self.ui.gamesListWidget.setCurrentRow(0) - self.games_found = True def add_existing_games(self) -> None: for game_id in self.config_manager.get_games_sorted_by_priority(): diff --git a/tests/onelauncher/test_cli.py b/tests/onelauncher/test_cli.py index c79dd4b6..58b2a37b 100644 --- a/tests/onelauncher/test_cli.py +++ b/tests/onelauncher/test_cli.py @@ -89,12 +89,13 @@ async def test_no_config(app: cyclopts.App, mocker: MockerFixture) -> None: assert app([]) == 0 async_mock.assert_called_once() - mock = mocker.patch.object(main, "SetupWizard") + mock = mocker.patch.object(main, "SetupWizard", autospec=True) mock_instance = mock.return_value - mock_instance.exec.return_value = QtWidgets.QDialog.DialogCode.Rejected + mock_instance.result.return_value = QtWidgets.QDialog.DialogCode.Rejected await async_mock.call_args.kwargs["entry"]() - mock_instance.exec.assert_called_once() + mock_instance.run.assert_called_once() + mock_instance.result.assert_called_once() async def test_no_games( @@ -109,14 +110,15 @@ async def test_no_games( async_mock.assert_called_once() mocker.patch.object(QtWidgets.QMessageBox, "information") - mock = mocker.patch.object(main, "SetupWizard") + mock = mocker.patch.object(main, "SetupWizard", autospec=True) mock_instance = mock.return_value - mock_instance.exec.return_value = QtWidgets.QDialog.DialogCode.Rejected + mock_instance.result.return_value = QtWidgets.QDialog.DialogCode.Rejected await async_mock.call_args.kwargs["entry"]() mock.assert_called_once() assert mock.call_args.kwargs["game_selection_only"] is True - mock_instance.exec.assert_called_once() + mock_instance.run.assert_called_once() + mock_instance.result.assert_called_once() def test_invalid_program_config( From 1ad3efb2e26b6cd9e2440865604111315efc499b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 21 Dec 2025 11:24:18 -0600 Subject: [PATCH 49/97] fix(patch_game): fix akamai patching with Nuitka This will hopefully be reverted later when this is fixed in Nuitka. --- src/onelauncher/patch_game.py | 36 +++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index 5b13b7a5..0b50dbe0 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -263,12 +263,13 @@ async def _handle_akamai_download_file( progress_item.total = download_file.size try: - async with ( - get_httpx_client(url).stream( - "GET", url, timeout=httpx.Timeout(6, pool=None) - ) as response, - await temp_download_path.open("wb") as temp_download_file, - ): + # Using the `async with client.stream(...)` currently doesn't work with + # Nuitka. See . + request = get_httpx_client(url).build_request( + "GET", url, timeout=httpx.Timeout(6, pool=None) + ) + response = await get_httpx_client(url).send(request, stream=True) + try: response.raise_for_status() bytes_currently_downloaded = response.num_bytes_downloaded @@ -277,16 +278,19 @@ async def _handle_akamai_download_file( response.headers.get("Content-Length", 300000) ) - async for chunk in response.aiter_bytes(): - if isinstance(download_file, SplashscreenDownloadFile): - progress_item.completed += ( - response.num_bytes_downloaded - bytes_currently_downloaded - ) - bytes_currently_downloaded = response.num_bytes_downloaded - else: - progress_item.completed += len(chunk) - - await temp_download_file.write(chunk) + async with await temp_download_path.open("wb") as temp_download_file: + async for chunk in response.aiter_bytes(): + if isinstance(download_file, SplashscreenDownloadFile): + progress_item.completed += ( + response.num_bytes_downloaded - bytes_currently_downloaded + ) + bytes_currently_downloaded = response.num_bytes_downloaded + else: + progress_item.completed += len(chunk) + + await temp_download_file.write(chunk) + finally: + await response.aclose() except HTTPError: logger.warning("Failed to download %s", local_path.name, exc_info=True) progress.progress_items.remove(progress_item) From 61c26138992c941d411db8b2af321bfc702d1984 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 21 Dec 2025 11:25:45 -0600 Subject: [PATCH 50/97] fix: restrict keyboard interupts to checkpoints --- src/onelauncher/async_utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/async_utils.py b/src/onelauncher/async_utils.py index 1406ec47..b57637b9 100644 --- a/src/onelauncher/async_utils.py +++ b/src/onelauncher/async_utils.py @@ -54,7 +54,7 @@ def launch_guest_run(self) -> None: self.entry, run_sync_soon_threadsafe=self.next_guest_run_schedule, done_callback=self.trio_done_callback, - strict_exception_groups=True, + restrict_keyboard_interrupt_to_checkpoints=True, ) def next_guest_run_schedule(self, fn: Callable[[], object]) -> None: @@ -89,7 +89,10 @@ def start_async(entry: Callable[[], Awaitable[None]]) -> int: Returns: int: Exit code """ - trio.run(partial(_scope_entry, entry=entry)) + trio.run( + partial(_scope_entry, entry=entry), + restrict_keyboard_interrupt_to_checkpoints=True, + ) return 0 From c9773f4b4555c4b90e886eef7a79bdca10b3a078 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 09:58:42 -0600 Subject: [PATCH 51/97] refactor: raise `RelativePathError` in `CaseInsensitiveAbsolutePath` --- src/onelauncher/utilities.py | 9 ++++++++- tests/onelauncher/test_utilities.py | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/utilities.py b/src/onelauncher/utilities.py index dc499e41..df2a30d5 100644 --- a/src/onelauncher/utilities.py +++ b/src/onelauncher/utilities.py @@ -35,6 +35,7 @@ from typing import TYPE_CHECKING, Self from xml.etree.ElementTree import Element +import attrs from defusedxml import ElementTree # type: ignore[import-untyped] from typing_extensions import override @@ -43,6 +44,9 @@ logger = logging.getLogger(__name__) +@attrs.frozen(kw_only=True) +class RelativePathError(ValueError): + msg: str = "Path is not absolute" class CaseInsensitiveAbsolutePath(Path): """ @@ -56,6 +60,9 @@ class CaseInsensitiveAbsolutePath(Path): LOTRO and DDO treat both patchclient.dll and PatchClient.dll the same, and both have been encountered in real game folders before. There are similar concerns regarding addon folders or anything else used by the games or in the WINE prefixes. + + Raises: + RelativePathError: Path is not absolute """ _flavour = ( # spellchecker:disable-line @@ -67,7 +74,7 @@ class CaseInsensitiveAbsolutePath(Path): def __new__(cls, *pathsegments: StrPath) -> Self: normal_path = Path(*pathsegments) if not normal_path.is_absolute(): - raise ValueError("Path is not absolute") + raise RelativePathError() path = cls._get_real_path_from_fully_case_insensitive_path(normal_path) return super().__new__(cls, path) diff --git a/tests/onelauncher/test_utilities.py b/tests/onelauncher/test_utilities.py index 7929256c..08717a1b 100644 --- a/tests/onelauncher/test_utilities.py +++ b/tests/onelauncher/test_utilities.py @@ -6,7 +6,7 @@ import onelauncher import onelauncher.utilities -from onelauncher.utilities import CaseInsensitiveAbsolutePath +from onelauncher.utilities import CaseInsensitiveAbsolutePath, RelativePathError class TestCaseInsensitiveAbsolutePath: @@ -28,6 +28,13 @@ def test_case_insensitive_path(self, tmp_path: Path) -> None: assert CaseInsensitiveAbsolutePath(tmp_path / paths[0]) == real_path + def test_relative_path(self) -> None: + with pytest.raises(RelativePathError): + CaseInsensitiveAbsolutePath() + + with pytest.raises(RelativePathError): + CaseInsensitiveAbsolutePath("a/b") + def test_no_matching_path(self, tmp_path: Path) -> None: """No changes are made to the path when any part of it can't be found""" (tmp_path / "afolder").mkdir() From f6621d488a942f79884d775d3d45ac55291c5794 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:06:29 -0600 Subject: [PATCH 52/97] refactor: move `Progress` from `patch_game.py` to `utilities.py` --- src/onelauncher/patch_game.py | 76 +-------------------------- src/onelauncher/patch_game_window.py | 2 +- src/onelauncher/utilities.py | 78 +++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 77 deletions(-) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index 0b50dbe0..e2cb8d82 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -2,7 +2,6 @@ import os import subprocess from functools import partial -from math import log, trunc from pathlib import Path from types import MappingProxyType from typing import Literal, TypeAlias, assert_never @@ -27,7 +26,7 @@ from onelauncher.network.game_launcher_config import GameLauncherConfig from onelauncher.network.httpx_client import get_httpx_client from onelauncher.resources import data_dir -from onelauncher.utilities import CaseInsensitiveAbsolutePath +from onelauncher.utilities import CaseInsensitiveAbsolutePath, Progress, ProgressItem from onelauncher.wine_environment import get_wine_process_args logger = logging.getLogger(__name__) @@ -51,79 +50,6 @@ """ -@attrs.define(eq=False) -class ProgressItem: - completed: int = 0 - total: int = 0 - - -@attrs.frozen -class CurrentProgress: - completed: int - total: int - progress_text: str - - -@attrs.define -class Progress: - progress_items: list[ProgressItem] = attrs.Factory(list) - unit_type: Literal["byte"] | None = None - progress_text_suffix: str = "" - - def reset(self) -> None: - self.progress_items = [] - self.unit_type = None - self.progress_text_suffix = "" - - def _pick_unit_and_suffix( - self, size: int, suffixes: tuple[str, ...], base: int - ) -> tuple[int, str]: - if not suffixes: - return 1, "" - - ideal_exponent = trunc(log(size, base)) - exponent = min(ideal_exponent, len(suffixes) - 1) - return base**exponent, suffixes[exponent] - - def get_current_progress(self) -> CurrentProgress: - sum_completed = 0 - sum_total = 0 - for progress_item in self.progress_items: - sum_completed += progress_item.completed - sum_total += progress_item.total - - # Don't want >100%. - sum_completed = min(sum_completed, sum_total) - - if sum_total == 0: - return CurrentProgress( - completed=0, total=0, progress_text=self.progress_text_suffix - ) - - if self.unit_type is None: - unit, suffix = 1, "" - elif self.unit_type == "byte": - unit, suffix = self._pick_unit_and_suffix( - size=sum_total, - suffixes=("bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), - base=1000, - ) - else: - assert_never() - precision = 0 if unit == 1 else 1 - completed_str = f"{sum_completed / unit:,.{precision}f}" - total_str = f"{sum_total / unit:,.{precision}f}" - progress_text = f"{sum_completed / sum_total:.0%} ({completed_str}/{total_str} {suffix}){self.progress_text_suffix}" - - return CurrentProgress( - # Using 0 to 10,000 instead of 0 to `current_progress.total` to prevent - # overfloq errors. - completed=round(sum_completed / sum_total * 10000), - total=10000, - progress_text=progress_text, - ) - - class PatchingProgressMonitor: def __init__(self, progress: Progress) -> None: self.progress = progress diff --git a/src/onelauncher/patch_game_window.py b/src/onelauncher/patch_game_window.py index 26020bc4..52d71ba1 100644 --- a/src/onelauncher/patch_game_window.py +++ b/src/onelauncher/patch_game_window.py @@ -41,11 +41,11 @@ from .config_manager import ConfigManager from .patch_game import ( PATCH_CLIENT_RUNNER, - Progress, patch_game, ) from .patch_game import logger as patch_game_logger from .ui.patching_window_uic import Ui_patchingDialog +from .utilities import Progress logger = logging.getLogger(__name__) diff --git a/src/onelauncher/utilities.py b/src/onelauncher/utilities.py index df2a30d5..cabe06bc 100644 --- a/src/onelauncher/utilities.py +++ b/src/onelauncher/utilities.py @@ -31,8 +31,9 @@ import os import pathlib from collections.abc import Generator +from math import log, trunc from pathlib import Path -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Literal, Self, assert_never from xml.etree.ElementTree import Element import attrs @@ -44,10 +45,12 @@ logger = logging.getLogger(__name__) + @attrs.frozen(kw_only=True) class RelativePathError(ValueError): msg: str = "Path is not absolute" + class CaseInsensitiveAbsolutePath(Path): """ `pathlib.Path` subclass that automatically converts from the provided @@ -179,6 +182,79 @@ def relative_to(self, *other: StrPath) -> Path: # type: ignore[override] return Path(self).relative_to(*other) +@attrs.define(eq=False) +class ProgressItem: + completed: int = 0 + total: int = 0 + + +@attrs.frozen +class CurrentProgress: + completed: int + total: int + progress_text: str + + +@attrs.define +class Progress: + progress_items: list[ProgressItem] = attrs.Factory(list) + unit_type: Literal["byte"] | None = None + progress_text_suffix: str = "" + + def reset(self) -> None: + self.progress_items = [] + self.unit_type = None + self.progress_text_suffix = "" + + def _pick_unit_and_suffix( + self, size: int, suffixes: tuple[str, ...], base: int + ) -> tuple[int, str]: + if not suffixes: + return 1, "" + + ideal_exponent = trunc(log(size, base)) + exponent = min(ideal_exponent, len(suffixes) - 1) + return base**exponent, suffixes[exponent] + + def get_current_progress(self) -> CurrentProgress: + sum_completed = 0 + sum_total = 0 + for progress_item in self.progress_items: + sum_completed += progress_item.completed + sum_total += progress_item.total + + # Don't want >100%. + sum_completed = min(sum_completed, sum_total) + + if sum_total == 0: + return CurrentProgress( + completed=0, total=0, progress_text=self.progress_text_suffix + ) + + if self.unit_type is None: + unit, suffix = 1, "" + elif self.unit_type == "byte": + unit, suffix = self._pick_unit_and_suffix( + size=sum_total, + suffixes=("bytes", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"), + base=1000, + ) + else: + assert_never() + precision = 0 if unit == 1 else 1 + completed_str = f"{sum_completed / unit:,.{precision}f}" + total_str = f"{sum_total / unit:,.{precision}f}" + progress_text = f"{sum_completed / sum_total:.0%} ({completed_str}/{total_str} {suffix}){self.progress_text_suffix}" + + return CurrentProgress( + # Using 0 to 10,000 instead of 0 to `current_progress.total` to prevent + # overfloq errors. + completed=round(sum_completed / sum_total * 10000), + total=10000, + progress_text=progress_text, + ) + + class AppSettingsParseError(KeyError): """Config doesn't follow the appSettings format""" From 3c10176a1da56c06529bc228e291cb29bd48b65f Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:19:29 -0600 Subject: [PATCH 53/97] refactor: `patch_game_window.py` -> `ui/patch_game.py` --- src/onelauncher/main_window.py | 4 ++-- .../{patch_game_window.py => ui/patch_game.py} | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) rename src/onelauncher/{patch_game_window.py => ui/patch_game.py} (94%) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index f3ac80c3..5d047e39 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -77,11 +77,11 @@ WorldLoginQueue, WorldQueueResultXMLParseError, ) -from .patch_game_window import PatchWindow from .resources import get_resource from .settings_window import SettingsWindow from .ui.about_uic import Ui_dlgAbout from .ui.main_uic import Ui_winMain +from .ui.patch_game import PatchGameWindow from .ui.select_subscription_uic import Ui_dlgSelectSubscription from .ui_utilities import log_record_to_rich_text, show_message_box_details_as_markdown @@ -337,7 +337,7 @@ async def actionPatchSelected(self) -> None: if game_services_info is None: return - patch_window = PatchWindow( + patch_window = PatchGameWindow( game_id=self.game_id, config_manager=self.config_manager, patch_server_url=game_services_info.patch_server, diff --git a/src/onelauncher/patch_game_window.py b/src/onelauncher/ui/patch_game.py similarity index 94% rename from src/onelauncher/patch_game_window.py rename to src/onelauncher/ui/patch_game.py index 52d71ba1..7f8a360b 100644 --- a/src/onelauncher/patch_game_window.py +++ b/src/onelauncher/ui/patch_game.py @@ -33,24 +33,24 @@ from qtpy import QtGui from typing_extensions import override +from onelauncher.config_manager import ConfigManager from onelauncher.game_config import GameConfigID from onelauncher.logs import ForwardLogsHandler -from onelauncher.qtapp import get_qapp -from onelauncher.ui_utilities import log_record_to_rich_text - -from .config_manager import ConfigManager -from .patch_game import ( +from onelauncher.patch_game import ( PATCH_CLIENT_RUNNER, patch_game, ) -from .patch_game import logger as patch_game_logger -from .ui.patching_window_uic import Ui_patchingDialog -from .utilities import Progress +from onelauncher.patch_game import logger as patch_game_logger +from onelauncher.qtapp import get_qapp +from onelauncher.ui_utilities import log_record_to_rich_text +from onelauncher.utilities import Progress + +from .patching_window_uic import Ui_patchingDialog logger = logging.getLogger(__name__) -class PatchWindow(QtWidgets.QDialog): +class PatchGameWindow(QtWidgets.QDialog): def __init__( self, game_id: GameConfigID, From 2b33958a9cd99366494ed2f0168fe86dab286cda Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:21:36 -0600 Subject: [PATCH 54/97] refactor: `ui/patching_window.ui` -> `ui/patch_game.ui` --- src/onelauncher/ui/patch_game.py | 2 +- src/onelauncher/ui/{patching_window.ui => patch_game.ui} | 0 .../ui/{patching_window_uic.py => patch_game_uic.py} | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/onelauncher/ui/{patching_window.ui => patch_game.ui} (100%) rename src/onelauncher/ui/{patching_window_uic.py => patch_game_uic.py} (100%) diff --git a/src/onelauncher/ui/patch_game.py b/src/onelauncher/ui/patch_game.py index 7f8a360b..2029086a 100644 --- a/src/onelauncher/ui/patch_game.py +++ b/src/onelauncher/ui/patch_game.py @@ -45,7 +45,7 @@ from onelauncher.ui_utilities import log_record_to_rich_text from onelauncher.utilities import Progress -from .patching_window_uic import Ui_patchingDialog +from .patch_game_uic import Ui_patchingDialog logger = logging.getLogger(__name__) diff --git a/src/onelauncher/ui/patching_window.ui b/src/onelauncher/ui/patch_game.ui similarity index 100% rename from src/onelauncher/ui/patching_window.ui rename to src/onelauncher/ui/patch_game.ui diff --git a/src/onelauncher/ui/patching_window_uic.py b/src/onelauncher/ui/patch_game_uic.py similarity index 100% rename from src/onelauncher/ui/patching_window_uic.py rename to src/onelauncher/ui/patch_game_uic.py From f91e280563fcdade16d40ea8e88b8a82c9591717 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:22:36 -0600 Subject: [PATCH 55/97] feat: log messages shown with `show_warning_message` --- src/onelauncher/ui_utilities.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/onelauncher/ui_utilities.py b/src/onelauncher/ui_utilities.py index dbde3704..b6f1163e 100644 --- a/src/onelauncher/ui_utilities.py +++ b/src/onelauncher/ui_utilities.py @@ -6,6 +6,8 @@ def show_warning_message(message: str, parent: QtWidgets.QWidget | None) -> None: + logger.warning(message) + message_box = QtWidgets.QMessageBox(parent) message_box.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint) message_box.setIcon(QtWidgets.QMessageBox.Icon.Warning) From c079a3f32d287c2a0795b1d6a333bce52ad3a06c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:26:56 -0600 Subject: [PATCH 56/97] refactor: `ui_utilities.py` -> `ui/utilities.py` --- src/onelauncher/main_window.py | 2 +- src/onelauncher/settings_window.py | 2 +- src/onelauncher/setup_wizard.py | 2 +- src/onelauncher/ui/patch_game.py | 2 +- src/onelauncher/{ui_utilities.py => ui/utilities.py} | 0 src/onelauncher/wine_environment.py | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename src/onelauncher/{ui_utilities.py => ui/utilities.py} (100%) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 5d047e39..a2baf395 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -83,7 +83,7 @@ from .ui.main_uic import Ui_winMain from .ui.patch_game import PatchGameWindow from .ui.select_subscription_uic import Ui_dlgSelectSubscription -from .ui_utilities import log_record_to_rich_text, show_message_box_details_as_markdown +from .ui.utilities import log_record_to_rich_text, show_message_box_details_as_markdown logger = logging.getLogger(__name__) diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 79fa6724..9121712a 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -60,7 +60,7 @@ from .standard_game_launcher import get_standard_game_launcher_path from .ui.custom_widgets import FramelessQDialogWithStylePreview from .ui.settings_uic import Ui_dlgSettings -from .ui_utilities import show_warning_message +from .ui.utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath from .wine_environment import get_wine_process_args diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index fd1d4923..792a7f17 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -64,7 +64,7 @@ from .program_config import GamesSortingMode, ProgramConfig from .resources import available_locales from .ui.setup_wizard_uic import Ui_Wizard -from .ui_utilities import show_warning_message +from .ui.utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath from .v1x_config_migrator import ( V1xConfigParseError, diff --git a/src/onelauncher/ui/patch_game.py b/src/onelauncher/ui/patch_game.py index 2029086a..3e932367 100644 --- a/src/onelauncher/ui/patch_game.py +++ b/src/onelauncher/ui/patch_game.py @@ -42,10 +42,10 @@ ) from onelauncher.patch_game import logger as patch_game_logger from onelauncher.qtapp import get_qapp -from onelauncher.ui_utilities import log_record_to_rich_text from onelauncher.utilities import Progress from .patch_game_uic import Ui_patchingDialog +from .utilities import log_record_to_rich_text logger = logging.getLogger(__name__) diff --git a/src/onelauncher/ui_utilities.py b/src/onelauncher/ui/utilities.py similarity index 100% rename from src/onelauncher/ui_utilities.py rename to src/onelauncher/ui/utilities.py diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 6f7136ff..02d58e88 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -48,7 +48,7 @@ from onelauncher.qtapp import get_qapp from .config import platform_dirs -from .ui_utilities import show_warning_message +from .ui.utilities import show_warning_message from .wine.config import WineConfigSection logger = logging.getLogger(__name__) From c73c7e541443165059c7c591039f5a568bf6d5ff Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:30:39 -0600 Subject: [PATCH 57/97] docs(settings_window): update tooltip --- src/onelauncher/ui/settings.ui | 2 +- src/onelauncher/ui/settings_uic.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/ui/settings.ui b/src/onelauncher/ui/settings.ui index 819fcf49..2e8c3eac 100644 --- a/src/onelauncher/ui/settings.ui +++ b/src/onelauncher/ui/settings.ui @@ -141,7 +141,7 @@ - Select game directory from file system + Select game install directory from the file browser ... diff --git a/src/onelauncher/ui/settings_uic.py b/src/onelauncher/ui/settings_uic.py index 1bbfbfd5..5538bf07 100644 --- a/src/onelauncher/ui/settings_uic.py +++ b/src/onelauncher/ui/settings_uic.py @@ -421,7 +421,7 @@ def retranslateUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: self.gameDirLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Game install directory. There should be a file called patchclient.dll here", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameDirButton.setToolTip(QCoreApplication.translate("dlgSettings", u"Select game directory from file system", None)) + self.gameDirButton.setToolTip(QCoreApplication.translate("dlgSettings", u"Select game install directory from the file browser", None)) #endif // QT_CONFIG(tooltip) self.gameDirButton.setText(QCoreApplication.translate("dlgSettings", u"...", None)) #if QT_CONFIG(tooltip) From b5d2b08bd0be20999fb5cd1617304f85d3603075 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 10:47:02 -0600 Subject: [PATCH 58/97] fix(settings_window): close before showing other windows post setup wizard --- src/onelauncher/settings_window.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 9121712a..3064787a 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -438,9 +438,10 @@ async def start_setup_wizard(self, games_managing: bool = False) -> None: ) await setup_wizard.run() - for widget in visible_tope_level_widgets: - widget.show() self.accept() + for widget in visible_tope_level_widgets: + if widget is not self: + widget.show() def add_languages_to_combobox(self, combobox: QtWidgets.QComboBox) -> None: for locale in available_locales.values(): From 0b045296c58095455452704f476080dbf8fa0818 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 11:02:25 -0600 Subject: [PATCH 59/97] refactor: `qtapp.py` -> `ui/qtapp.py` --- src/onelauncher/addon_manager.py | 7 +++---- src/onelauncher/async_utils.py | 2 +- src/onelauncher/main.py | 2 +- src/onelauncher/main_window.py | 11 +++++------ src/onelauncher/network/game_newsfeed.py | 2 +- src/onelauncher/settings_window.py | 9 ++++----- src/onelauncher/setup_wizard.py | 3 +-- src/onelauncher/ui/custom_widgets.py | 8 ++++---- src/onelauncher/ui/patch_game.py | 2 +- src/onelauncher/{ => ui}/qtapp.py | 6 +++--- src/onelauncher/wine_environment.py | 3 +-- 11 files changed, 25 insertions(+), 30 deletions(-) rename src/onelauncher/{ => ui}/qtapp.py (95%) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager.py index ff93d108..56f0737b 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager.py @@ -63,10 +63,6 @@ from PySide6 import QtCore, QtGui, QtWidgets from typing_extensions import override -from onelauncher.network.httpx_client import get_httpx_client_sync -from onelauncher.qtapp import get_qapp -from onelauncher.ui.qtdesigner.custom_widgets import QWidgetWithStylePreview - from .__about__ import __title__ from .addons.startup_script import StartupScript from .config import platform_dirs @@ -74,7 +70,10 @@ from .game_config import GameConfigID, GameType from .game_launcher_local_config import GameLauncherLocalConfig from .game_utilities import get_game_settings_dir +from .network.httpx_client import get_httpx_client_sync from .ui.addon_manager_uic import Ui_winAddonManager +from .ui.qtapp import get_qapp +from .ui.qtdesigner.custom_widgets import QWidgetWithStylePreview from .utilities import CaseInsensitiveAbsolutePath if TYPE_CHECKING: diff --git a/src/onelauncher/async_utils.py b/src/onelauncher/async_utils.py index b57637b9..ba3acdc3 100644 --- a/src/onelauncher/async_utils.py +++ b/src/onelauncher/async_utils.py @@ -9,7 +9,7 @@ from trio.abc import ReceiveStream from typing_extensions import override -from onelauncher.qtapp import get_qapp +from .ui.qtapp import get_qapp logger = logging.getLogger(__name__) diff --git a/src/onelauncher/main.py b/src/onelauncher/main.py index 9fcaf251..8291af49 100644 --- a/src/onelauncher/main.py +++ b/src/onelauncher/main.py @@ -12,9 +12,9 @@ ) from .game_config import GameConfigID from .main_window import MainWindow -from .qtapp import get_qapp from .setup_wizard import SetupWizard from .ui.error_message_uic import Ui_errorDialog +from .ui.qtapp import get_qapp logger = logging.getLogger(__name__) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index a2baf395..0537efdf 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -42,13 +42,8 @@ from typing_extensions import override from xmlschema import XMLSchemaValidationError -from onelauncher.addons.startup_script import run_startup_script -from onelauncher.logs import ForwardLogsHandler -from onelauncher.qtapp import get_app_style, get_qapp -from onelauncher.start_game import MissingLaunchArgumentError, start_game -from onelauncher.ui.custom_widgets import FramelessQMainWindowWithStylePreview - from . import __about__, addon_manager +from .addons.startup_script import run_startup_script from .config_manager import ConfigManager, NoValidGamesError from .game_account_config import GameAccountConfig from .game_config import GameConfigID, GameType @@ -62,6 +57,7 @@ find_game_dir_game_type, get_game_settings_dir, ) +from .logs import ForwardLogsHandler from .network import login_account from .network.game_launcher_config import ( GameLauncherConfig, @@ -79,9 +75,12 @@ ) from .resources import get_resource from .settings_window import SettingsWindow +from .start_game import MissingLaunchArgumentError, start_game from .ui.about_uic import Ui_dlgAbout +from .ui.custom_widgets import FramelessQMainWindowWithStylePreview from .ui.main_uic import Ui_winMain from .ui.patch_game import PatchGameWindow +from .ui.qtapp import get_app_style, get_qapp from .ui.select_subscription_uic import Ui_dlgSelectSubscription from .ui.utilities import log_record_to_rich_text, show_message_box_details_as_markdown diff --git a/src/onelauncher/network/game_newsfeed.py b/src/onelauncher/network/game_newsfeed.py index eff421cc..e9faa202 100644 --- a/src/onelauncher/network/game_newsfeed.py +++ b/src/onelauncher/network/game_newsfeed.py @@ -9,7 +9,7 @@ from babel.dates import format_datetime from PySide6 import QtCore -from onelauncher.qtapp import get_qapp +from onelauncher.ui.qtapp import get_qapp from .httpx_client import get_httpx_client diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 3064787a..81cd99fb 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -39,15 +39,13 @@ from PySide6 import QtCore, QtGui, QtWidgets from typing_extensions import override -from onelauncher.game_launcher_local_config import ( - GameLauncherLocalConfig, -) -from onelauncher.qtapp import get_qapp - from .__about__ import __title__ from .config import platform_dirs from .config_manager import ConfigManager from .game_config import ClientType, GameConfigID +from .game_launcher_local_config import ( + GameLauncherLocalConfig, +) from .game_utilities import ( InvalidGameDirError, find_game_dir_game_type, @@ -59,6 +57,7 @@ from .setup_wizard import SetupWizard from .standard_game_launcher import get_standard_game_launcher_path from .ui.custom_widgets import FramelessQDialogWithStylePreview +from .ui.qtapp import get_qapp from .ui.settings_uic import Ui_dlgSettings from .ui.utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 792a7f17..0257e905 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -39,8 +39,6 @@ from PySide6 import QtCore, QtGui, QtWidgets from typing_extensions import override -from onelauncher.qtapp import get_app_style, get_qapp - from .__about__ import __title__ from .addons.config import AddonsConfigSection from .config_manager import ConfigManager @@ -63,6 +61,7 @@ from .official_clients import get_game_icon, is_gls_url_for_preview_client from .program_config import GamesSortingMode, ProgramConfig from .resources import available_locales +from .ui.qtapp import get_app_style, get_qapp from .ui.setup_wizard_uic import Ui_Wizard from .ui.utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath diff --git a/src/onelauncher/ui/custom_widgets.py b/src/onelauncher/ui/custom_widgets.py index 1ab34083..a66929e1 100644 --- a/src/onelauncher/ui/custom_widgets.py +++ b/src/onelauncher/ui/custom_widgets.py @@ -2,14 +2,14 @@ from qframelesswindow import FramelessDialog, FramelessMainWindow from typing_extensions import override -from onelauncher.qtapp import get_qapp -from onelauncher.ui.qtdesigner.custom_widgets import ( +from onelauncher.network.game_newsfeed import get_newsfeed_css + +from .qtapp import get_qapp +from .qtdesigner.custom_widgets import ( QDialogWithStylePreview, QMainWindowWithStylePreview, ) -from ..network.game_newsfeed import get_newsfeed_css - class FramelessQDialogWithStylePreview(FramelessDialog, QDialogWithStylePreview): ... diff --git a/src/onelauncher/ui/patch_game.py b/src/onelauncher/ui/patch_game.py index 3e932367..d53233fd 100644 --- a/src/onelauncher/ui/patch_game.py +++ b/src/onelauncher/ui/patch_game.py @@ -41,10 +41,10 @@ patch_game, ) from onelauncher.patch_game import logger as patch_game_logger -from onelauncher.qtapp import get_qapp from onelauncher.utilities import Progress from .patch_game_uic import Ui_patchingDialog +from .qtapp import get_qapp from .utilities import log_record_to_rich_text logger = logging.getLogger(__name__) diff --git a/src/onelauncher/qtapp.py b/src/onelauncher/ui/qtapp.py similarity index 95% rename from src/onelauncher/qtapp.py rename to src/onelauncher/ui/qtapp.py index a5188fde..77b90081 100644 --- a/src/onelauncher/qtapp.py +++ b/src/onelauncher/ui/qtapp.py @@ -33,10 +33,10 @@ import qtawesome from PySide6 import QtCore, QtGui, QtWidgets -from onelauncher.ui.style import ApplicationStyle +from onelauncher.__about__ import __title__, __version__ +from onelauncher.resources import data_dir -from .__about__ import __title__, __version__ -from .resources import data_dir +from .style import ApplicationStyle @cache diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index 02d58e88..86f8d52a 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -45,9 +45,8 @@ import certifi from PySide6 import QtCore, QtWidgets -from onelauncher.qtapp import get_qapp - from .config import platform_dirs +from .ui.qtapp import get_qapp from .ui.utilities import show_warning_message from .wine.config import WineConfigSection From f085dc4507f2334c9654cd992d8d60662b169333 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 17:00:55 -0600 Subject: [PATCH 60/97] fix(patch_game): create target directories for download files --- src/onelauncher/patch_game.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index e2cb8d82..49820cd7 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -221,6 +221,7 @@ async def _handle_akamai_download_file( logger.warning("Failed to download %s", local_path.name, exc_info=True) progress.progress_items.remove(progress_item) else: + await local_path.parent.mkdir(parents=True, exist_ok=True) await temp_download_path.rename(local_path) finally: with trio.move_on_after(5, shield=True): From 566b772b003b954caf855c3d0f51933c622bbdc7 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 17:01:54 -0600 Subject: [PATCH 61/97] fix(patch_game): don't warn about 404 errors in akamai patching They are expected. --- src/onelauncher/patch_game.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index 49820cd7..baec5ea0 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -10,7 +10,7 @@ import attrs import httpx import trio -from httpx import HTTPError +from httpx import HTTPError, HTTPStatusError from xmlschema import XMLSchemaValidationError from onelauncher.async_utils import for_each_in_stream @@ -217,8 +217,15 @@ async def _handle_akamai_download_file( await temp_download_file.write(chunk) finally: await response.aclose() - except HTTPError: - logger.warning("Failed to download %s", local_path.name, exc_info=True) + except HTTPError as e: + if ( + isinstance(e, HTTPStatusError) + and e.response.status_code == httpx.codes.NOT_FOUND + ): + # Not an error, because there are always some specific files that 404. + logger.debug("Download not found: %s", local_path.name, exc_info=True) + else: + logger.exception("Failed to download %s", local_path.name) progress.progress_items.remove(progress_item) else: await local_path.parent.mkdir(parents=True, exist_ok=True) From e07d733963e5b92c8b33acc6354d9b7064dffaaa Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 20:10:12 -0600 Subject: [PATCH 62/97] fix(setup_wizard): keep open while settings are being saved --- src/onelauncher/setup_wizard.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 0257e905..5d789181 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -180,7 +180,10 @@ def data_deletion_page_update() -> None: self.ui.keepDataRadioButton.setChecked(True) # Finished page - self.accepted.connect(self.save_settings) + self.button(QtWidgets.QWizard.WizardButton.FinishButton).clicked.disconnect() + self.button(QtWidgets.QWizard.WizardButton.FinishButton).clicked.connect( + self.finish + ) if self.game_selection_only: for page_id in self.pageIds(): @@ -553,6 +556,10 @@ def sort_list_widget_items( items_dict = {item.listWidget().row(item): item for item in items} return [items_dict[key] for key in sorted(items_dict)] + def finish(self) -> None: + self.save_settings() + self.accept() + def save_settings(self) -> None: if not self.game_selection_only: selected_locale_display_name = ( From a786e9db6519a77d555d0fc958b961720cce341a Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 20:10:55 -0600 Subject: [PATCH 63/97] fix(setup_wizard): set `checked=True` for user added game dirs --- src/onelauncher/setup_wizard.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 5d789181..6dc67a8f 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -536,6 +536,7 @@ def browse_for_game_dir(self) -> None: self.add_game( game_id=generate_game_config_id(game_config), game_config=game_config, + checked=True, selected=True, ) except InvalidGameDirError: From 21d7094d50001f55ddb5e6d2dd544dee0aefc276 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 20:13:33 -0600 Subject: [PATCH 64/97] fix(setup_wizard): improve QFileDialog usage --- src/onelauncher/setup_wizard.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 6dc67a8f..0bcb0762 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -514,9 +514,10 @@ def browse_for_game_dir(self) -> None: game_dir_string = QtWidgets.QFileDialog.getExistingDirectory( self, - "Game Directory", + "Select Game Directory", str(starting_dir), - options=QtWidgets.QFileDialog.Option.ShowDirsOnly, + options=QtWidgets.QFileDialog.Option.ShowDirsOnly + | QtWidgets.QFileDialog.Option.DontResolveSymlinks, ) if not game_dir_string: return From 90a612dc706bb15477499cfbd109c0206874dbb3 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 20:46:53 -0600 Subject: [PATCH 65/97] refactor: use `onelauncher/external` directory for `run_ptch_client.exe` --- .github/workflows/build.yml | 4 +++- CONTRIBUTING.md | 2 +- build/nuitka_compile.py | 4 ++-- flake.nix | 2 +- src/onelauncher/external/.gitignore | 2 ++ src/onelauncher/patch_game.py | 4 ++-- src/onelauncher/resources.py | 1 + 7 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 src/onelauncher/external/.gitignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bf60edad..0ae68d7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: artifact_rename: OneLauncher-Windows.msi steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 v6.0.1 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: persist-credentials: false @@ -54,6 +54,8 @@ jobs: if: runner.os == 'Windows' shell: msys2 {0} run: make -C src/run_patch_client + - name: Symlink `run_ptch_client` to `onelauncher/external` dir + run: ln -s ../../run_patch_client/run_ptch_client.exe src/onelauncher/external/run_ptch_client.exe # Python # Can't use uv python with Nuitka yet diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 844badc3..10198a0b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Contributions and questions are always welcome! Here's just a couple of things t OneLauncher uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for dependency management. Run `uv run onelauncher` in the root folder of this repository to install and start OneLauncher. Alternatively, [Nix can be used](#nix). -For game patching support, extra C code must be compiled. Run `make -C src/run_patch_client` with mingw-w64 installed. Your mingw-w64 installation must have support for i686 builds. +For game patching support, extra C code must be compiled with mingw-w64. Run `make -C src/run_patch_client && ln -s ../../run_patch_client/run_ptch_client.exe src/onelauncher/external/run_ptch_client.exe`. Your mingw-w64 installation must have support for i686 builds. ### Nix diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index e1b18aa1..41a1bf92 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -21,7 +21,7 @@ def main( extra_args: Iterable[str] = (), ) -> None: nuitka_arguments = [ - f"--output-dir={Path(__file__) / 'out'}", + f"--output-dir={Path(__file__).parent / 'out'}", "--onefile" if onefile_mode else "--standalone", "--python-flag=-m", # Package mode. Compile as "package.__main__" "--python-flag=isolated", @@ -32,7 +32,7 @@ def main( "--noinclude-unittest-mode=nofollow", "--noinclude-pytest-mode=nofollow", "--enable-plugins=pyside6", - "--include-data-files=src/run_patch_client/run_ptch_client.exe=run_patch_client/run_ptch_client.exe", + "--include-data-files=src/onelauncher/external/run_ptch_client.exe=onelauncher/external/run_ptch_client.exe", "--include-data-files=src/onelauncher/=onelauncher/=**/*.xsd", "--include-data-dir=src/onelauncher/images=onelauncher/images", "--include-data-dir=src/onelauncher/locale=onelauncher/locale", diff --git a/flake.nix b/flake.nix index 87003509..91f41b17 100644 --- a/flake.nix +++ b/flake.nix @@ -284,7 +284,7 @@ ln --force --symbolic "${ pkgs.callPackage ./src/run_patch_client { } - }/bin/run_ptch_client.exe" ./src/run_patch_client/run_ptch_client.exe + }/bin/run_ptch_client.exe" ./src/onelauncher/external/run_ptch_client.exe ''; }; } diff --git a/src/onelauncher/external/.gitignore b/src/onelauncher/external/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/src/onelauncher/external/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index baec5ea0..ca91c45e 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -25,7 +25,7 @@ ) from onelauncher.network.game_launcher_config import GameLauncherConfig from onelauncher.network.httpx_client import get_httpx_client -from onelauncher.resources import data_dir +from onelauncher.resources import external_dependencies_dir from onelauncher.utilities import CaseInsensitiveAbsolutePath, Progress, ProgressItem from onelauncher.wine_environment import get_wine_process_args @@ -42,7 +42,7 @@ "DataOnly", ) -PATCH_CLIENT_RUNNER = data_dir.parent / "run_patch_client" / "run_ptch_client.exe" +PATCH_CLIENT_RUNNER = external_dependencies_dir / "run_ptch_client.exe" """ Executable used to run `patchclient.dll` and get output from it. This is done with a separate program, because `patchclient.dll` is 32-bit. `rundll32.exe` can't be used, diff --git a/src/onelauncher/resources.py b/src/onelauncher/resources.py index b8ad702c..a6f1105f 100644 --- a/src/onelauncher/resources.py +++ b/src/onelauncher/resources.py @@ -175,5 +175,6 @@ def get_game_dir_available_locales(game_dir: Path) -> list[OneLauncherLocale]: data_dir = get_data_dir() +external_dependencies_dir = data_dir / "external" available_locales = get_available_locales() system_locale = get_system_locale() From 5e35bac1d6470e0527fbeed67e8c9b4108e161de Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 21:04:20 -0600 Subject: [PATCH 66/97] fix: don't start game when there are world queue errors --- src/onelauncher/main_window.py | 43 ++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 0537efdf..b4b1cf22 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -589,7 +589,7 @@ async def authenticate_account( logger.info("Account authenticated") return login_response - async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: + async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: # noqa: PLR0911 current_account = self.get_current_game_account() current_world: World = self.ui.cboWorld.currentData() if current_account is None: @@ -680,12 +680,23 @@ async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: return if selected_world_status.queue_url: - await self.world_queue( - queueURL=selected_world_status.queue_url, - account_number=account_number, - login_response=login_response, - game_launcher_config=game_launcher_config, - ) + try: + await self.world_queue( + queueURL=selected_world_status.queue_url, + account_number=account_number, + login_response=login_response, + game_launcher_config=game_launcher_config, + ) + except httpx.HTTPError: + logger.exception("Network error while joining world queue") + return + except (JoinWorldQueueFailedError, WorldQueueResultXMLParseError): + logger.exception( + "Non-network error joining world queue. " + "Please report this error if it continues" + ) + return + self.run_startup_scripts() logger.info("Starting game") self.ui.btnStartGame.setText("Abort") @@ -729,6 +740,12 @@ async def world_queue( login_response: login_account.AccountLoginResponse, game_launcher_config: GameLauncherConfig, ) -> None: + """ + Raises: + HTTPError + JoinWorldQueueFailedError + WorldQueueResultXMLParseError + """ world_login_queue = WorldLoginQueue( game_launcher_config.login_queue_url, game_launcher_config.login_queue_params_template, @@ -737,17 +754,7 @@ async def world_queue( queueURL, ) while True: - try: - world_queue_result = await world_login_queue.join_queue() - except httpx.HTTPError: - logger.exception("Network error while joining world queue") - return - except (JoinWorldQueueFailedError, WorldQueueResultXMLParseError): - logger.exception( - "Non-network error joining world queue. " - "Please report this error if it continues" - ) - return + world_queue_result = await world_login_queue.join_queue() if world_queue_result.queue_number <= world_queue_result.now_serving_number: break people_ahead_in_queue = ( From b6f786194a14eb46d66740cf2102861c2c6fab80 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 22 Dec 2025 21:32:47 -0600 Subject: [PATCH 67/97] feat: game install support This is disabled on Windows for now pending testing. --- .github/workflows/build.yml | 66 +++++- CONTRIBUTING.md | 3 +- build/nuitka_compile.py | 1 + build/nuitka_package_config.yml | 9 + flake.nix | 1 + src/onelauncher/async_utils.py | 61 ++++++ src/onelauncher/install_game.py | 279 +++++++++++++++++++++++++ src/onelauncher/setup_wizard.py | 48 ++++- src/onelauncher/ui/install_game.py | 204 ++++++++++++++++++ src/onelauncher/ui/install_game.ui | 151 +++++++++++++ src/onelauncher/ui/install_game_uic.py | 119 +++++++++++ src/onelauncher/ui/setup_wizard.ui | 19 +- src/onelauncher/ui/setup_wizard_uic.py | 20 +- 13 files changed, 970 insertions(+), 11 deletions(-) create mode 100644 build/nuitka_package_config.yml create mode 100644 src/onelauncher/install_game.py create mode 100644 src/onelauncher/ui/install_game.py create mode 100644 src/onelauncher/ui/install_game.ui create mode 100644 src/onelauncher/ui/install_game_uic.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ae68d7b..82654763 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,8 +54,54 @@ jobs: if: runner.os == 'Windows' shell: msys2 {0} run: make -C src/run_patch_client - - name: Symlink `run_ptch_client` to `onelauncher/external` dir - run: ln -s ../../run_patch_client/run_ptch_client.exe src/onelauncher/external/run_ptch_client.exe + - name: Move `run_ptch_client` to `onelauncher/external` dir + run: mv src/run_patch_client/run_ptch_client.exe src/onelauncher/external/ + + # innoextract + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + if: runner.os != 'Windows' + with: + repository: dscharrer/innoextract + ref: 6e9e34ed0876014fdb46e684103ef8c3605e382e + path: innoextract + persist-credentials: false + - name: Patch innoextract to fix build with boost 1.89.0 + if: runner.os != 'Windows' + run: | + pushd innoextract + + curl https://github.com/dscharrer/innoextract/commit/882796e0e9b134b02deeaae4bbfe92920adb6fe2.patch \ + | git apply + + popd + - name: Install innoextract dependencies on Linux + if: runner.os == 'Linux' + run: sudo apt-get install libboost-all-dev liblzma-dev + - name: Install innoextract dependencies on macOS + if: runner.os == 'macOS' + run: brew install boost + - name: Build innoextract on macOS + if: runner.os == 'macOS' + run: | + mkdir innoextract/build + pushd innoextract/build + + LDFLAGS="-L$(brew --prefix zstd)/lib -L$(brew --prefix icu4c@78)/lib" cmake .. + make + + popd + ln -s $PWD/innoextract/build/innoextract src/onelauncher/external/innoextract + - name: Build innoextract on Linux + if: runner.os == 'Linux' + run: | + mkdir innoextract/build + pushd innoextract/build + + cmake .. -DUSE_STATIC_LIBS=ON + make + + popd + ln -s $PWD/innoextract/build/innoextract src/onelauncher/external/innoextract # Python # Can't use uv python with Nuitka yet @@ -101,6 +147,22 @@ jobs: - name: Build run: python -m build + - name: Verify build + shell: bash + run: | + if [ "${{ runner.os }}" == "macOS" ]; then + distDir="build/out/onelauncher.app/Contents/MacOS" + else + distDir="build/out/onelauncher.dist" + fi + + test -x $distDir/onelauncher/external/run_ptch_client.exe && \ + echo run_ptch_client.exe found + + if [ "${{ runner.os }}" != "Windows" ]; then + test -x $distDir/onelauncher/external/innoextract && \ + echo innoextract found + fi - name: Make app zip on MacOS if: runner.os == 'macOS' run: ditto -c -k --keepParent build/out/onelauncher.app build/out/onelauncher.zip diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10198a0b..d33654e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,8 @@ Contributions and questions are always welcome! Here's just a couple of things t OneLauncher uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for dependency management. Run `uv run onelauncher` in the root folder of this repository to install and start OneLauncher. Alternatively, [Nix can be used](#nix). -For game patching support, extra C code must be compiled with mingw-w64. Run `make -C src/run_patch_client && ln -s ../../run_patch_client/run_ptch_client.exe src/onelauncher/external/run_ptch_client.exe`. Your mingw-w64 installation must have support for i686 builds. +For game patching support, extra C code must be compiled with mingw-w64. Run `make -C src/run_patch_client && mv src/run_patch_client/run_ptch_client.exe src/onelauncher/external/`. Your mingw-w64 installation must have support for i686 builds. +innoextract is needed for game installation support. ### Nix diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index 41a1bf92..b068ab43 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -21,6 +21,7 @@ def main( extra_args: Iterable[str] = (), ) -> None: nuitka_arguments = [ + f"--user-package-configuration-file={Path(__file__).parent / 'nuitka_package_config.yml'}", f"--output-dir={Path(__file__).parent / 'out'}", "--onefile" if onefile_mode else "--standalone", "--python-flag=-m", # Package mode. Compile as "package.__main__" diff --git a/build/nuitka_package_config.yml b/build/nuitka_package_config.yml new file mode 100644 index 00000000..da35e553 --- /dev/null +++ b/build/nuitka_package_config.yml @@ -0,0 +1,9 @@ +- module-name: "onelauncher" + dlls: + - from_filenames: + relative_path: "external" + prefixes: + - "innoextract" + executable: "yes" + import-hacks: + - package-system-dlls: "yes" diff --git a/flake.nix b/flake.nix index 91f41b17..c41b47d2 100644 --- a/flake.nix +++ b/flake.nix @@ -242,6 +242,7 @@ pkgs.mkShell { packages = [ virtualenv + pkgs.innoextract (pkgs.runCommand "onelauncher-shell-completions" { nativeBuildInputs = [ diff --git a/src/onelauncher/async_utils.py b/src/onelauncher/async_utils.py index ba3acdc3..c236d1a3 100644 --- a/src/onelauncher/async_utils.py +++ b/src/onelauncher/async_utils.py @@ -1,6 +1,8 @@ import logging from collections.abc import Awaitable, Callable from functools import partial +from tempfile import TemporaryDirectory +from types import TracebackType from typing import Final import outcome @@ -113,3 +115,62 @@ async def for_each_in_stream(pipe: ReceiveStream, func: Callable[[str], None]) - for line in chunk.decode("utf-8").split("\n"): if stripped_line := line.strip(): func(stripped_line) + + +# Based on `anyio` code. +class TemporaryDirectoryAsyncPath: + """ + An asynchronous temporary directory that is created and cleaned up automatically. + + This class provides an asynchronous context manager for creating a temporary + directory. It wraps Python's standard :class:`~tempfile.TemporaryDirectory` to + perform directory creation and cleanup operations in a background thread, and + returns it as a `trio.Path`. + + :param suffix: Suffix to be added to the temporary directory name. + :param prefix: Prefix to be added to the temporary directory name. + :param dir: The parent directory where the temporary directory is created. + :param ignore_cleanup_errors: Whether to ignore errors during cleanup + """ + + def __init__( + self, + suffix: str | None = None, + prefix: str | None = None, + dir: str | None = None, # noqa: A002 + *, + ignore_cleanup_errors: bool = False, + ) -> None: + self.suffix: str | None = suffix + self.prefix: str | None = prefix + self.dir: str | None = dir + self.ignore_cleanup_errors = ignore_cleanup_errors + + self._tempdir: TemporaryDirectory[str] | None = None + + async def __aenter__(self) -> trio.Path: + self._tempdir = await trio.to_thread.run_sync( + partial( + TemporaryDirectory, + suffix=self.suffix, + prefix=self.prefix, + dir=self.dir, + ignore_cleanup_errors=self.ignore_cleanup_errors, + ) + ) + return trio.Path(await trio.to_thread.run_sync(self._tempdir.__enter__)) + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + if self._tempdir is not None: + await trio.to_thread.run_sync( + self._tempdir.__exit__, exc_type, exc_value, traceback + ) + + async def cleanup(self) -> None: + if self._tempdir is not None: + await trio.to_thread.run_sync(self._tempdir.cleanup) diff --git a/src/onelauncher/install_game.py b/src/onelauncher/install_game.py new file mode 100644 index 00000000..62340389 --- /dev/null +++ b/src/onelauncher/install_game.py @@ -0,0 +1,279 @@ +import logging +import os +import shutil +import sys +from pathlib import Path +from subprocess import CalledProcessError +from tempfile import NamedTemporaryFile +from typing import Final + +import attrs +import trio +from httpx import HTTPError + +from .addons.config import AddonsConfigSection +from .async_utils import TemporaryDirectoryAsyncPath +from .config_manager import ConfigManager +from .game_config import ( + GameConfig, + GameConfigID, + GameType, + generate_game_config_id, +) +from .game_utilities import InvalidGameDirError, find_game_dir_game_type +from .logs import ExternalProcessLogsFilter +from .network.httpx_client import get_httpx_client +from .official_clients import get_game_icon +from .resources import data_dir, external_dependencies_dir +from .utilities import ( + CaseInsensitiveAbsolutePath, + Progress, + ProgressItem, + RelativePathError, +) +from .wine.config import WineConfigSection + +logger = logging.getLogger(__name__) +logger.addFilter(ExternalProcessLogsFilter()) + + +@attrs.frozen +class GameInstaller: + name: str + icon_path: Path + url: str + game_type: GameType + is_preview_client: bool + + +GAME_INSTALLERS: Final[tuple[GameInstaller, ...]] = ( + GameInstaller( + name="The Lord of The Rings Online", + icon_path=get_game_icon(GameType.LOTRO), + url="https://akamai.lotro.com/lotro/lotrolive.exe", + game_type=GameType.LOTRO, + is_preview_client=False, + ), + GameInstaller( + name="The Lord of the Rings Online - Public Preview", + icon_path=get_game_icon(GameType.LOTRO), + url="https://files.lotro.com/lotro/installers/preview/lotropreview.exe", + game_type=GameType.LOTRO, + is_preview_client=True, + ), + GameInstaller( + name="Dungeons & Dragons Online", + icon_path=get_game_icon(GameType.DDO), + url="https://akamai.ddo.com/ddo/ddolive.exe", + game_type=GameType.DDO, + is_preview_client=False, + ), + GameInstaller( + name="Dungeons & Dragons Online - Public Preview", + icon_path=get_game_icon(GameType.DDO), + url="https://files.ddo.com/ddo/installers/preview/ddopreview.exe", + game_type=GameType.DDO, + is_preview_client=True, + ), +) + + +def get_default_game_config( + installer: GameInstaller, config_manager: ConfigManager +) -> tuple[GameConfigID, GameConfig]: + """ + Return a default `GameConfigID` and `GameConfig` for `installer`. The default game + directory is in the game config directory for this `GameConfigID`. That can be + replaced with a user selected directory if the `GameConfig` is updated. + You have to add these to `config_manager` yourself. + """ + game_config = GameConfig( + game_directory=CaseInsensitiveAbsolutePath.home(), # temporary + game_type=installer.game_type, + is_preview_client=installer.is_preview_client, + addons=AddonsConfigSection(), + wine=WineConfigSection(), + ) + game_config_id = generate_game_config_id(game_config) + game_config = attrs.evolve( + game_config, + game_directory=CaseInsensitiveAbsolutePath( + config_manager.get_game_config_dir(game_config_id) / "game_install" + ), + ) + + return game_config_id, game_config + + +@attrs.frozen(kw_only=True) +class InstallDirValidationError(ValueError): + msg: str + + +def validate_user_provided_install_dir( + install_dir_string: str, + config_manager: ConfigManager, + default_install_dir: CaseInsensitiveAbsolutePath, +) -> CaseInsensitiveAbsolutePath: + """ + Validate user provided game installation directory string and return the path. + The `.msg` of the `InstallDirValidationError` raised is meant to be shown to the + user. + + Raises: + InstallDirValidationError: Show `.msg` to the user. + """ + if not install_dir_string.strip(): + return default_install_dir + + try: + install_dir = CaseInsensitiveAbsolutePath(install_dir_string) + except RelativePathError as e: + raise InstallDirValidationError( + msg="Install directory cannot be a relative path" + ) from e + + # Default install directory as gotten from `get_default_game_config` won't exist, + # but is still considered valid. + if install_dir == default_install_dir: + return install_dir + + try: + file = install_dir.open() + file.close() + except PermissionError as e: + raise InstallDirValidationError(msg="Install directory must be readable") from e + except FileNotFoundError as e: + raise InstallDirValidationError(msg="Install directory must exist") from e + except IsADirectoryError: + pass + else: + raise InstallDirValidationError(msg="Install directory must be a directory") + + if next(install_dir.iterdir(), None): + raise InstallDirValidationError(msg="Install directory must be empty") + + try: + test_file = install_dir / "tmp_test_if_dir_writable" + test_file.write_text("(:") + test_file.unlink() + except OSError as e: + raise InstallDirValidationError(msg="Install directory must be writable") from e + + if install_dir.is_relative_to(config_manager.games_dir): + raise InstallDirValidationError( + msg=( + "Install directory can only be under " + f"{config_manager.games_dir} if using the generated default path" + ), + ) + + return CaseInsensitiveAbsolutePath(install_dir) + + +def get_innoextract_path() -> Path: + """ + Raises: + FileNotFoundError: innoextract not found + """ + our_innoextract_path = external_dependencies_dir / ( + "innoextract.exe" if os.name == "nt" else "innoextract" + ) + if our_innoextract_path.exists(): + return our_innoextract_path + + system_innoextract = shutil.which("innoextract") + if not system_innoextract: + raise FileNotFoundError( + "innoextract not found in filesystem or PATH", our_innoextract_path + ) + + return Path(system_innoextract) + + +@attrs.frozen(kw_only=True) +class InstallGameError(Exception): + msg: str + + +async def install_game( + *, + installer: GameInstaller, + install_dir: CaseInsensitiveAbsolutePath, + progress: Progress, +) -> None: + """ + Create a new game install at `install_dir` from `installer`. `install_dir` should + be pre-validated with `validate_user_provided_install_dir` if it was user provided. + + Raises: + InstallGameError: Error while installing the game. Show `.msg` to the user. + """ + try: + logger.info("Downloading %s game installer", installer.name) + progress.unit_type = "byte" + download_progress_item = ProgressItem() + progress.progress_items.append(download_progress_item) + async with ( + get_httpx_client(installer.url).stream("GET", installer.url) as response, + trio.wrap_file(NamedTemporaryFile()) as installer_file, + TemporaryDirectoryAsyncPath() as extract_dir, + ): + response.raise_for_status() + + bytes_currently_downloaded = response.num_bytes_downloaded + download_progress_item.total = int( + response.headers.get("Content-Length", 46000000) + ) + async for chunk in response.aiter_bytes(): + download_progress_item.completed += ( + response.num_bytes_downloaded - bytes_currently_downloaded + ) + bytes_currently_downloaded = response.num_bytes_downloaded + await installer_file.write(chunk) + + logger.info("Extracting %s game installer", installer.name) + progress.reset() + try: + completed_process = await trio.run_process( + ( + get_innoextract_path(), + "--exclude-temp", + "--output-dir", + extract_dir, + installer_file.name, + ), + # On macOS with Nuitka, dependencies of innoextract will be in + # the data dir parent, but the executable won't be configured + # properly to look for them there. + env={"DYLD_FALLBACK_LIBRARY_PATH": str(data_dir.parent)} + if sys.platform == "darwin" and "__compiled__" in globals() + else None, + capture_stdout=True, + capture_stderr=True, + ) + except CalledProcessError as e: + e.add_note("stdout: \n" + e.stdout.decode().strip()) + e.add_note("stderr: \n" + e.stderr.decode().strip()) + raise InstallGameError(msg="Installer extraction failed") from e + logger.debug( + "innoextract stdout: \n %s", completed_process.stdout.decode().strip() + ) + + # Verify extracted game dir. + try: + find_game_dir_game_type( + CaseInsensitiveAbsolutePath(extract_dir) / "app" + ) + except InvalidGameDirError as e: + raise InstallGameError( + msg="Installer extraction did not create a valid game directory" + ) from e + + # Move the extracted game directory to `install_dir`. + if install_dir.exists(): + install_dir.rmdir() + install_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.move(extract_dir / "app", install_dir) + except HTTPError as e: + raise InstallGameError(msg="Failed to download the game installer") from e diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 0bcb0762..bb70cdb9 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -31,6 +31,7 @@ from contextlib import suppress from functools import partial from pathlib import Path +from shutil import rmtree from typing import Final import attrs @@ -61,6 +62,7 @@ from .official_clients import get_game_icon, is_gls_url_for_preview_client from .program_config import GamesSortingMode, ProgramConfig from .resources import available_locales +from .ui.install_game import InstallGameWindow from .ui.qtapp import get_app_style, get_qapp from .ui.setup_wizard_uic import Ui_Wizard from .ui.utilities import show_warning_message @@ -128,6 +130,8 @@ def __init__( self.setWindowTitle("Setup Wizard") self.migrate_old_config_asked: bool = False + self.new_install_game_ids: list[GameConfigID] = [] + """Game config IDs for installs created in this setup wizard""" def setup_ui(self) -> None: # As of PySide 6.1, other styles don't have right spacing or work with the dark @@ -144,7 +148,15 @@ def setup_ui(self) -> None: # Games discovery page self.ui.gamesSelectionWizardPage.validatePage = self.validateGamesSelectionPage # type: ignore[method-assign] - self.ui.addGameButton.clicked.connect(self.browse_for_game_dir) + self.ui.addExistingGameButton.clicked.connect(self.browse_for_game_dir) + self.ui.installGameButton.clicked.connect( + lambda: self.nursery.start_soon(self.install_game) + ) + if os.name == "nt": + # Game installation is disabled on Windows for now pending extra testing. + # See #313 (internal issue tracker). + self.ui.installGameButton.hide() + self.ui.addExistingGameButton.setText("Add Game") self.ui.upPriorityButton.clicked.connect(self.raise_selected_game_priority) self.ui.downPriorityButton.clicked.connect(self.lower_selected_game_priority) @@ -213,6 +225,16 @@ async def run(self) -> None: self.nursery.start_soon(trio.sleep_forever) def cleanup(self) -> None: + if self.result() == QtWidgets.QDialog.DialogCode.Rejected: + # Delete new game installs that are in the OneLauncher games + # directory. + for new_install_game_id in self.new_install_game_ids: + with suppress(FileNotFoundError): + rmtree( + self.config_manager.get_game_config_dir( + game_id=new_install_game_id + ) + ) self.nursery.cancel_scope.cancel() @override @@ -543,6 +565,18 @@ def browse_for_game_dir(self) -> None: except InvalidGameDirError: show_warning_message("Not a valid game installation folder", self) + async def install_game(self) -> None: + install_game_window = InstallGameWindow(config_manager=self.config_manager) + await install_game_window.run() + if install_game_window.result() == QtWidgets.QDialog.DialogCode.Accepted: + self.new_install_game_ids.append(install_game_window.game_id) + self.add_game( + game_id=install_game_window.game_id, + game_config=install_game_window.game_config, + checked=True, + selected=True, + ) + def get_selected_game_items(self) -> list[QtWidgets.QListWidgetItem]: items = [] for i in range(self.ui.gamesListWidget.count()): @@ -616,6 +650,18 @@ def add_games_to_settings(self) -> None: for game_id in not_selected_existing_game_ids: self.config_manager.delete_game_config(game_id=game_id) + # Delete unselected new game installs that are in the OneLauncher games + # directory. Users are responsible for any installs they created in a custom + # directory. + for new_install_game_id in self.new_install_game_ids: + if new_install_game_id not in selected_games: + with suppress(FileNotFoundError): + rmtree( + self.config_manager.get_game_config_dir( + game_id=new_install_game_id + ) + ) + existing_game_names = [ game_config.name for game_id, game_config in selected_games.items() diff --git a/src/onelauncher/ui/install_game.py b/src/onelauncher/ui/install_game.py new file mode 100644 index 00000000..934f2e8d --- /dev/null +++ b/src/onelauncher/ui/install_game.py @@ -0,0 +1,204 @@ +import logging +import os +from functools import partial +from pathlib import Path +from typing import Final + +import attrs +import qtawesome +import trio +from PySide6 import QtCore, QtGui, QtWidgets +from typing_extensions import override + +from onelauncher.config_manager import ConfigManager +from onelauncher.install_game import ( + GAME_INSTALLERS, + GameInstaller, + InstallDirValidationError, + InstallGameError, + get_default_game_config, + get_innoextract_path, + install_game, + validate_user_provided_install_dir, +) +from onelauncher.utilities import Progress + +from .custom_widgets import FramelessQDialogWithStylePreview +from .install_game_uic import Ui_installGameDialog +from .qtapp import get_qapp +from .utilities import show_warning_message + +logger = logging.getLogger(__name__) + +GameInstallerRole: Final[int] = QtCore.Qt.ItemDataRole.UserRole + 1001 + + +class InstallGameWindow(FramelessQDialogWithStylePreview): + def __init__(self, config_manager: ConfigManager) -> None: + super().__init__(get_qapp().activeWindow()) + self.config_manager = config_manager + self.progress: Progress | None = None + + def setup_ui(self) -> None: + self.titleBar.hide() + + self.ui = Ui_installGameDialog() + self.ui.setupUi(self) + color_scheme_changed = get_qapp().styleHints().colorSchemeChanged + + self.ui.progressBar.hide() + + for installer in GAME_INSTALLERS: + item = QtWidgets.QListWidgetItem(installer.name) + item.setData(GameInstallerRole, installer) + item.setIcon(QtGui.QIcon(str(installer.icon_path))) + self.ui.gameTypeListWidget.addItem(item) + self.ui.gameTypeListWidget.currentItemChanged.connect( + self.current_installer_item_changed + ) + self.game_id, self.game_config = get_default_game_config( + installer=self.ui.gameTypeListWidget.item(0).data(GameInstallerRole), + config_manager=self.config_manager, + ) + self.ui.gameTypeListWidget.setCurrentRow(0) + self.ui.gameTypeListWidget.setFocus() + + get_select_folder_icon = partial(qtawesome.icon, "mdi6.folder-open-outline") + self.ui.selectInstallDirButton.setIcon(get_select_folder_icon()) + color_scheme_changed.connect( + lambda: self.ui.selectInstallDirButton.setIcon(get_select_folder_icon()) + ) + self.ui.selectInstallDirButton.clicked.connect(self.browse_for_install_dir) + + self.install_button = self.ui.buttonBox.addButton( + "Install Game", QtWidgets.QDialogButtonBox.ButtonRole.AcceptRole + ) + self.install_button.clicked.connect( + lambda: self.nursery.start_soon(self.install_game) + ) + + self.adjustSize() + self.open() + + async def run(self) -> None: + try: + get_innoextract_path() + except FileNotFoundError: + logger.exception("") + show_warning_message( + "innoextract not found. Cannot make a new game install.", None + ) + self.reject() + return + + self.setup_ui() + async with trio.open_nursery() as self.nursery: + self.finished.connect(self.cleanup) + + self.nursery.start_soon(self.keep_progress_bar_updated) + # Will be canceled when the winddow is closed. + self.nursery.start_soon(trio.sleep_forever) + + def cleanup(self) -> None: + self.nursery.cancel_scope.cancel() + + @override + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self.cleanup() + event.accept() + + @override + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + # Let the user drag the window when left-click holding it. + if event.button() == QtCore.Qt.MouseButton.LeftButton: + self.windowHandle().startSystemMove() + event.accept() + + def current_installer_item_changed( + self, current: QtWidgets.QListWidgetItem, _previous: QtWidgets.QListWidgetItem + ) -> None: + installer: GameInstaller = current.data(GameInstallerRole) + new_game_id, new_game_config = get_default_game_config( + installer=installer, config_manager=self.config_manager + ) + + # Don't overwrite custom user install directories. + if not self.ui.installDirLineEdit.text().strip() or ( + self.ui.installDirLineEdit.text() == str(self.game_config.game_directory) + ): + self.ui.installDirLineEdit.setText(str(new_game_config.game_directory)) + self.ui.installDirLineEdit.setCursorPosition(0) + + self.game_id, self.game_config = new_game_id, new_game_config + + def browse_for_install_dir(self) -> None: + if os.name == "nt": + starting_dir = Path(os.environ.get("PROGRAMFILES", "C:/Program Files")) + else: + starting_dir = Path.home() + install_dir_string = QtWidgets.QFileDialog.getExistingDirectory( + self, + "Select Game Install Directory", + str(starting_dir), + options=QtWidgets.QFileDialog.Option.ShowDirsOnly + | QtWidgets.QFileDialog.Option.DontResolveSymlinks, + ) + if not install_dir_string: + return + + try: + install_dir = validate_user_provided_install_dir( + install_dir_string=install_dir_string, + config_manager=self.config_manager, + default_install_dir=self.game_config.game_directory, + ) + except InstallDirValidationError as e: + logger.warning(e.msg, exc_info=True) + show_warning_message(e.msg, self) + return + + self.ui.installDirLineEdit.setText(str(install_dir)) + + async def keep_progress_bar_updated(self) -> None: + # Will be canceled once the window is closed. + while True: + if self.progress: + current_progress = self.progress.get_current_progress() + self.ui.progressBar.setFormat(current_progress.progress_text) + self.ui.progressBar.setMaximum(current_progress.total) + self.ui.progressBar.setValue(current_progress.completed) + await trio.sleep(0.05) + + async def install_game(self) -> None: + try: + install_dir = validate_user_provided_install_dir( + install_dir_string=self.ui.installDirLineEdit.text(), + config_manager=self.config_manager, + default_install_dir=self.game_config.game_directory, + ) + except InstallDirValidationError as e: + logger.warning(e.msg, exc_info=True) + show_warning_message(e.msg, self) + return + self.game_config = attrs.evolve(self.game_config, game_directory=install_dir) + + self.ui.widgetInstallOptions.setEnabled(False) + self.install_button.setEnabled(False) + self.progress = Progress() + self.ui.progressBar.show() + + try: + await install_game( + installer=self.ui.gameTypeListWidget.currentItem().data( + GameInstallerRole + ), + install_dir=install_dir, + progress=self.progress, + ) + except InstallGameError as e: + logger.exception("") + show_warning_message(e.msg, self) + self.reject() + return + + self.accept() diff --git a/src/onelauncher/ui/install_game.ui b/src/onelauncher/ui/install_game.ui new file mode 100644 index 00000000..2e2775d0 --- /dev/null +++ b/src/onelauncher/ui/install_game.ui @@ -0,0 +1,151 @@ + + + installGameDialog + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 468 + 273 + + + + Install Game + + + true + + + + 9 + + + + + + QFormLayout::RowWrapPolicy::WrapAllRows + + + + + + + Directory where the game will be installed + + + + + + + Select game install directory from the file browser + + + Qt::ArrowType::NoArrow + + + + icon-base + + + + + + + + + + Directory where the game will be installed + + + Install Directory + + + + + + + Which game to install + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QListView::ResizeMode::Adjust + + + + + + + Which game to install + + + Game Type + + + + + + + + + + 24 + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel + + + + + + + + QDialogWithStylePreview + QDialog +
.qtdesigner.custom_widgets
+ 1 +
+ + FramelessQDialogWithStylePreview + QDialogWithStylePreview +
.custom_widgets
+ 1 +
+ + NoOddSizesQToolButton + QToolButton +
.custom_widgets
+
+
+ + + + buttonBox + rejected() + installGameDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
diff --git a/src/onelauncher/ui/install_game_uic.py b/src/onelauncher/ui/install_game_uic.py new file mode 100644 index 00000000..a3b641ad --- /dev/null +++ b/src/onelauncher/ui/install_game_uic.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'install_game.ui' +## +## Created by: Qt User Interface Compiler version 6.10.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QAbstractButton, QAbstractScrollArea, QApplication, QDialogButtonBox, + QFormLayout, QHBoxLayout, QLabel, QLineEdit, + QListView, QListWidget, QListWidgetItem, QProgressBar, + QSizePolicy, QVBoxLayout, QWidget) + +from .custom_widgets import (FramelessQDialogWithStylePreview, NoOddSizesQToolButton) +from .qtdesigner.custom_widgets import QDialogWithStylePreview + +class Ui_installGameDialog(object): + def setupUi(self, installGameDialog: FramelessQDialogWithStylePreview) -> None: + if not installGameDialog.objectName(): + installGameDialog.setObjectName(u"installGameDialog") + installGameDialog.setWindowModality(Qt.WindowModality.ApplicationModal) + installGameDialog.resize(468, 273) + installGameDialog.setModal(True) + self.verticalLayout = QVBoxLayout(installGameDialog) + self.verticalLayout.setSpacing(9) + self.verticalLayout.setObjectName(u"verticalLayout") + self.widgetInstallOptions = QWidget(installGameDialog) + self.widgetInstallOptions.setObjectName(u"widgetInstallOptions") + self.formLayout = QFormLayout(self.widgetInstallOptions) + self.formLayout.setObjectName(u"formLayout") + self.formLayout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapAllRows) + self.installDirLayout = QHBoxLayout() + self.installDirLayout.setObjectName(u"installDirLayout") + self.installDirLineEdit = QLineEdit(self.widgetInstallOptions) + self.installDirLineEdit.setObjectName(u"installDirLineEdit") + + self.installDirLayout.addWidget(self.installDirLineEdit) + + self.selectInstallDirButton = NoOddSizesQToolButton(self.widgetInstallOptions) + self.selectInstallDirButton.setObjectName(u"selectInstallDirButton") + self.selectInstallDirButton.setArrowType(Qt.ArrowType.NoArrow) + + self.installDirLayout.addWidget(self.selectInstallDirButton) + + + self.formLayout.setLayout(1, QFormLayout.ItemRole.FieldRole, self.installDirLayout) + + self.installDirLabel = QLabel(self.widgetInstallOptions) + self.installDirLabel.setObjectName(u"installDirLabel") + + self.formLayout.setWidget(1, QFormLayout.ItemRole.LabelRole, self.installDirLabel) + + self.gameTypeListWidget = QListWidget(self.widgetInstallOptions) + self.gameTypeListWidget.setObjectName(u"gameTypeListWidget") + self.gameTypeListWidget.setSizeAdjustPolicy(QAbstractScrollArea.SizeAdjustPolicy.AdjustToContents) + self.gameTypeListWidget.setResizeMode(QListView.ResizeMode.Adjust) + + self.formLayout.setWidget(0, QFormLayout.ItemRole.FieldRole, self.gameTypeListWidget) + + self.gameTypeLabel = QLabel(self.widgetInstallOptions) + self.gameTypeLabel.setObjectName(u"gameTypeLabel") + + self.formLayout.setWidget(0, QFormLayout.ItemRole.LabelRole, self.gameTypeLabel) + + + self.verticalLayout.addWidget(self.widgetInstallOptions) + + self.progressBar = QProgressBar(installGameDialog) + self.progressBar.setObjectName(u"progressBar") + self.progressBar.setValue(24) + + self.verticalLayout.addWidget(self.progressBar) + + self.buttonBox = QDialogButtonBox(installGameDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setOrientation(Qt.Orientation.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel) + + self.verticalLayout.addWidget(self.buttonBox) + + + self.retranslateUi(installGameDialog) + self.buttonBox.rejected.connect(installGameDialog.reject) + + QMetaObject.connectSlotsByName(installGameDialog) + # setupUi + + def retranslateUi(self, installGameDialog: FramelessQDialogWithStylePreview) -> None: + installGameDialog.setWindowTitle(QCoreApplication.translate("installGameDialog", u"Install Game", None)) +#if QT_CONFIG(tooltip) + self.installDirLineEdit.setToolTip(QCoreApplication.translate("installGameDialog", u"Directory where the game will be installed", None)) +#endif // QT_CONFIG(tooltip) +#if QT_CONFIG(tooltip) + self.selectInstallDirButton.setToolTip(QCoreApplication.translate("installGameDialog", u"Select game install directory from the file browser", None)) +#endif // QT_CONFIG(tooltip) + self.selectInstallDirButton.setProperty(u"qssClass", [ + QCoreApplication.translate("installGameDialog", u"icon-base", None)]) +#if QT_CONFIG(tooltip) + self.installDirLabel.setToolTip(QCoreApplication.translate("installGameDialog", u"Directory where the game will be installed", None)) +#endif // QT_CONFIG(tooltip) + self.installDirLabel.setText(QCoreApplication.translate("installGameDialog", u"Install Directory", None)) +#if QT_CONFIG(tooltip) + self.gameTypeListWidget.setToolTip(QCoreApplication.translate("installGameDialog", u"Which game to install", None)) +#endif // QT_CONFIG(tooltip) +#if QT_CONFIG(tooltip) + self.gameTypeLabel.setToolTip(QCoreApplication.translate("installGameDialog", u"Which game to install", None)) +#endif // QT_CONFIG(tooltip) + self.gameTypeLabel.setText(QCoreApplication.translate("installGameDialog", u"Game Type", None)) + # retranslateUi + diff --git a/src/onelauncher/ui/setup_wizard.ui b/src/onelauncher/ui/setup_wizard.ui index c4f131ec..5e15d894 100644 --- a/src/onelauncher/ui/setup_wizard.ui +++ b/src/onelauncher/ui/setup_wizard.ui @@ -206,9 +206,22 @@
- + + + Select an existing game directory from the file browser + + + Add Existing Game + + + + + + + Create a new game installation + - Add Game + Install New Game @@ -218,7 +231,7 @@
- Exisiting Games Data + Existing Games Data Some of your game installations are already registered with OneLauncher. You can choose to have their settings and accounts either kept or reset. Unselected games are always removed. diff --git a/src/onelauncher/ui/setup_wizard_uic.py b/src/onelauncher/ui/setup_wizard_uic.py index ebc8336e..70efb855 100644 --- a/src/onelauncher/ui/setup_wizard_uic.py +++ b/src/onelauncher/ui/setup_wizard_uic.py @@ -121,10 +121,15 @@ def setupUi(self, Wizard: QWizard) -> None: self.horizontalLayout.addItem(self.verticalSpacer) - self.addGameButton = QPushButton(self.gamesSelectionWizardPage) - self.addGameButton.setObjectName(u"addGameButton") + self.addExistingGameButton = QPushButton(self.gamesSelectionWizardPage) + self.addExistingGameButton.setObjectName(u"addExistingGameButton") - self.horizontalLayout.addWidget(self.addGameButton) + self.horizontalLayout.addWidget(self.addExistingGameButton) + + self.installGameButton = QPushButton(self.gamesSelectionWizardPage) + self.installGameButton.setObjectName(u"installGameButton") + + self.horizontalLayout.addWidget(self.installGameButton) self.gamesSelectionPageLayout.addLayout(self.horizontalLayout) @@ -209,7 +214,14 @@ def retranslateUi(self, Wizard: QWizard) -> None: self.upPriorityButton.setToolTip(QCoreApplication.translate("Wizard", u"Increase priority", None)) #endif // QT_CONFIG(tooltip) self.upPriorityButton.setText(QCoreApplication.translate("Wizard", u"\u2191", None)) - self.addGameButton.setText(QCoreApplication.translate("Wizard", u"Add Game", None)) +#if QT_CONFIG(tooltip) + self.addExistingGameButton.setToolTip(QCoreApplication.translate("Wizard", u"Select an existing game directory from the file browser", None)) +#endif // QT_CONFIG(tooltip) + self.addExistingGameButton.setText(QCoreApplication.translate("Wizard", u"Add Existing Game", None)) +#if QT_CONFIG(tooltip) + self.installGameButton.setToolTip(QCoreApplication.translate("Wizard", u"Create a new game installation", None)) +#endif // QT_CONFIG(tooltip) + self.installGameButton.setText(QCoreApplication.translate("Wizard", u"Install New Game", None)) self.dataDeletionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Existing Games Data", None)) self.dataDeletionWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"Some of your game installations are already registered with OneLauncher. You can choose to have their settings and accounts either kept or reset. Unselected games are always removed.", None)) self.groupBox.setTitle(QCoreApplication.translate("Wizard", u"What should happen to existing game data?", None)) From cff90d016035f98ce40a9b2396e0bc59233750b8 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 23 Dec 2025 00:00:55 -0600 Subject: [PATCH 68/97] build(deps): upgrade nuitka --- pyproject.toml | 4 +--- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b34daf1b..ccf4444c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,9 +67,7 @@ test = [ "pytest-trio>=0.8.0", ] build = [ - # See . - # Waiting on version 2.8.5 to make it to PyPi. - "Nuitka>=2.4.8, <=2.7.15", + "Nuitka>=2.4.8", "marko>=2.1.2", # For converting the icon image. "ImageIO>=2.37.2 ; sys_platform == 'darwin'", diff --git a/uv.lock b/uv.lock index a996cd2f..62c25f9c 100644 --- a/uv.lock +++ b/uv.lock @@ -507,13 +507,13 @@ wheels = [ [[package]] name = "nuitka" -version = "2.7.14" +version = "2.8.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ordered-set" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/11/005da9e0826ff333096763597699f5ac9ede8e57e31d94d52300e4bc8f1f/Nuitka-2.7.14.tar.gz", hash = "sha256:88233ed175d6d2abb2e1d5fa3c2e28b2fac604764ddc319c614325ff87c77117", size = 3888306, upload-time = "2025-09-08T09:47:05.979Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/fb/51df3b30b0f9b3e73f3ba6bea8b94516b16035297c4b3452aaa632a130ae/nuitka-2.8.9.tar.gz", hash = "sha256:b178cd437f2110c46943b368db51d20d57d586a13f8f6323ab1be4e51e2fabf8", size = 4332046, upload-time = "2025-11-29T11:32:20.733Z" } [[package]] name = "numpy" @@ -621,14 +621,14 @@ requires-dist = [ build = [ { name = "imageio", marker = "sys_platform == 'darwin'", specifier = ">=2.37.2" }, { name = "marko", specifier = ">=2.1.2" }, - { name = "nuitka", specifier = ">=2.4.8,<=2.7.15" }, + { name = "nuitka", specifier = ">=2.4.8" }, ] dev = [ { name = "imageio", marker = "sys_platform == 'darwin'", specifier = ">=2.37.2" }, { name = "marko", specifier = ">=2.1.2" }, { name = "mypy" }, { name = "mypy", specifier = ">=1.18.2" }, - { name = "nuitka", specifier = ">=2.4.8,<=2.7.15" }, + { name = "nuitka", specifier = ">=2.4.8" }, { name = "pyside6-essentials", specifier = ">=6.10.0" }, { name = "pytest", specifier = ">=8.3.2" }, { name = "pytest-mock", specifier = ">=3.15.1" }, From 94ec9a6fed4667db2c93d212e106793807b98780 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 23 Dec 2025 15:24:20 -0600 Subject: [PATCH 69/97] fix(install_game): downloading with Nuitka --- src/onelauncher/install_game.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/onelauncher/install_game.py b/src/onelauncher/install_game.py index 62340389..91af97b2 100644 --- a/src/onelauncher/install_game.py +++ b/src/onelauncher/install_game.py @@ -215,22 +215,32 @@ async def install_game( download_progress_item = ProgressItem() progress.progress_items.append(download_progress_item) async with ( - get_httpx_client(installer.url).stream("GET", installer.url) as response, trio.wrap_file(NamedTemporaryFile()) as installer_file, TemporaryDirectoryAsyncPath() as extract_dir, ): - response.raise_for_status() - - bytes_currently_downloaded = response.num_bytes_downloaded - download_progress_item.total = int( - response.headers.get("Content-Length", 46000000) - ) - async for chunk in response.aiter_bytes(): - download_progress_item.completed += ( - response.num_bytes_downloaded - bytes_currently_downloaded + try: + # Using the `async with client.stream(...)` currently doesn't work with + # Nuitka. See . + request = get_httpx_client(installer.url).build_request( + "GET", installer.url ) + response = await get_httpx_client(installer.url).send( + request, stream=True + ) + response.raise_for_status() + bytes_currently_downloaded = response.num_bytes_downloaded - await installer_file.write(chunk) + download_progress_item.total = int( + response.headers.get("Content-Length", 46000000) + ) + async for chunk in response.aiter_bytes(): + download_progress_item.completed += ( + response.num_bytes_downloaded - bytes_currently_downloaded + ) + bytes_currently_downloaded = response.num_bytes_downloaded + await installer_file.write(chunk) + finally: + await response.aclose() logger.info("Extracting %s game installer", installer.name) progress.reset() From b5c04503bf4fb7f269cad07588a78aefde9cde65 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 23 Dec 2025 15:24:54 -0600 Subject: [PATCH 70/97] feat(logs): log data dir --- src/onelauncher/logs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/onelauncher/logs.py b/src/onelauncher/logs.py index 0bc701eb..025ff8a5 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -12,6 +12,8 @@ from typing_extensions import override +from onelauncher.resources import data_dir + from .__about__ import __title__, __version__, version_parsed from .config import platform_dirs @@ -31,6 +33,7 @@ def log_basic_info(logger: logging.Logger) -> None: logger.info("Logging started") logger.info("%s: %s", __title__, __version__) logger.info(platform()) + logger.info("Data Dir: %s", data_dir) def handle_uncaught_exceptions( From d937bc01ec969573531f934b0eaed5b7f7d8dd42 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 10:09:25 -0600 Subject: [PATCH 71/97] build(nix): don't use private tmp in FHS env Makes it harder to inspect things. --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index c41b47d2..f7bee7b3 100644 --- a/flake.nix +++ b/flake.nix @@ -182,6 +182,7 @@ fhs-run = (pkgs.steam.override { extraPkgs = pkgs: [ pkgs.libz ]; + privateTmp = false; }).run-free; }; apps = { From 5156e9169533db11738f8d463489789cddc02c9c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 10:54:28 -0600 Subject: [PATCH 72/97] fix: cancel app on uncaught exception Exit code still won't be right, though. --- src/onelauncher/logs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/onelauncher/logs.py b/src/onelauncher/logs.py index 025ff8a5..58d07a24 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -12,6 +12,7 @@ from typing_extensions import override +from onelauncher.async_utils import app_cancel_scope from onelauncher.resources import data_dir from .__about__ import __title__, __version__, version_parsed @@ -48,6 +49,7 @@ def handle_uncaught_exceptions( sys.__excepthook__(exc_type, exc_value, exc_traceback) return logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + app_cancel_scope.cancel() class RedactHomeDirFormatter(logging.Formatter): From 4416af6b6141d6a28d5f6c21447580c9ae765c45 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 11:25:06 -0600 Subject: [PATCH 73/97] docs: mention using fhs-run for source OneLauncher --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d33654e9..13421a92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ innoextract is needed for game installation support. OneLauncher comes with a [Nix](https://nixos.org/) flake for easily replicating the standard development environment. It can be used with [direnv](https://github.com/direnv/direnv) or the `nix develop` command. -The compiled builds can be tested on NixOS with `nix run .#fhs-run build/out/onelauncher.bin`. +The compiled builds can be tested on NixOS with `nix run .#fhs-run build/out/onelauncher.bin`. Similarly, `nix run .#fhs-run onelauncher` can be used while in the development shell to start OneLauncher from source with support for the WINE binaries it downloads. ## Building From 4c7fefba503f322a4d30205fb1ca3510c512180a Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 13:03:02 -0600 Subject: [PATCH 74/97] fix(config_manager): add `exclude_install_dir` option to `delete_game_config` Now that the install dir may be inside the game config dir, we need to make sure that it isn't deleted on game config reset. --- src/onelauncher/config_manager.py | 25 +++++++++---- src/onelauncher/setup_wizard.py | 6 ++-- tests/onelauncher/conftest.py | 46 ++++++++++++++++++++++++ tests/onelauncher/test_cli.py | 41 --------------------- tests/onelauncher/test_config_manager.py | 26 ++++++++++++++ 5 files changed, 95 insertions(+), 49 deletions(-) create mode 100644 tests/onelauncher/conftest.py diff --git a/src/onelauncher/config_manager.py b/src/onelauncher/config_manager.py index bff3ad0a..5073d200 100644 --- a/src/onelauncher/config_manager.py +++ b/src/onelauncher/config_manager.py @@ -625,19 +625,32 @@ def update_game_config_file( self.verified_game_config_ids.append(game_id) self._cached_game_configs[game_id] = config - def delete_game_config(self, game_id: GameConfigID) -> None: + def delete_game_config( + self, game_id: GameConfigID, *, exclude_install_dir: bool = False + ) -> None: """Delete game config including all files and saved accounts""" + game_install_dir = self.read_game_config_file(game_id).game_directory + with suppress(FileNotFoundError): account_configs = self.read_game_accounts_config_file(game_id) for account_config in account_configs: self.delete_game_account_keyring_info( game_id=game_id, game_account=account_config ) - rmtree(self.get_game_config_dir(game_id=game_id)) - if game_id in self.verified_game_config_ids: - self.verified_game_config_ids.remove(game_id) - del self._cached_game_configs[game_id] - del self._cached_game_accounts_configs[game_id] + + for path in self.get_game_config_dir(game_id).glob("*"): + if exclude_install_dir and path == game_install_dir: + continue + if path.is_dir(): + rmtree(path) + else: + path.unlink() + with suppress(OSError): + self.get_game_config_dir(game_id).rmdir() + + self.verified_game_config_ids.remove(game_id) + del self._cached_game_configs[game_id] + del self._cached_game_accounts_configs[game_id] def get_game_accounts(self, game_id: GameConfigID) -> tuple[GameAccountConfig, ...]: if not self.configs_are_verified: diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index bb70cdb9..1201c066 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -633,7 +633,9 @@ def add_games_to_settings(self) -> None: if game_id not in existing_game_ids: continue - self.config_manager.delete_game_config(game_id) + self.config_manager.delete_game_config( + game_id, exclude_install_dir=True + ) reset_game_config = self.get_game_config_from_game_dir( game_config.game_directory ) @@ -648,7 +650,7 @@ def add_games_to_settings(self) -> None: if game_id not in selected_games ) for game_id in not_selected_existing_game_ids: - self.config_manager.delete_game_config(game_id=game_id) + self.config_manager.delete_game_config(game_id) # Delete unselected new game installs that are in the OneLauncher games # directory. Users are responsible for any installs they created in a custom diff --git a/tests/onelauncher/conftest.py b/tests/onelauncher/conftest.py new file mode 100644 index 00000000..6559d598 --- /dev/null +++ b/tests/onelauncher/conftest.py @@ -0,0 +1,46 @@ +from pathlib import Path + +import pytest + +from onelauncher.addons.config import AddonsConfigSection +from onelauncher.config_manager import ConfigManager +from onelauncher.game_config import GameConfig, GameType, generate_game_config_id +from onelauncher.utilities import CaseInsensitiveAbsolutePath +from onelauncher.wine.config import WineConfigSection + + +@pytest.fixture +def config_dir(tmp_path: Path) -> Path: + config_dir = tmp_path / "config" + config_dir.mkdir() + return config_dir + + +@pytest.fixture +def games_dir(tmp_path: Path) -> Path: + games_dir = tmp_path / "games" + games_dir.mkdir() + return games_dir + + +@pytest.fixture +def config_manager(config_dir: Path, games_dir: Path, tmp_path: Path) -> ConfigManager: + config_manager = ConfigManager(program_config_dir=config_dir, games_dir=games_dir) + config_manager.verify_configs() + + config_manager.update_program_config_file(config_manager.read_program_config_file()) + + mock_game_dir = CaseInsensitiveAbsolutePath(tmp_path / "lotro_game_dir") + mock_game_dir.mkdir() + game_config = GameConfig( + addons=AddonsConfigSection(), + wine=WineConfigSection(), + game_type=GameType.LOTRO, + is_preview_client=False, + game_directory=mock_game_dir, + ) + config_manager.update_game_config_file( + game_id=generate_game_config_id(game_config), config=game_config + ) + + return config_manager diff --git a/tests/onelauncher/test_cli.py b/tests/onelauncher/test_cli.py index 58b2a37b..664c972d 100644 --- a/tests/onelauncher/test_cli.py +++ b/tests/onelauncher/test_cli.py @@ -7,52 +7,11 @@ from pytest_mock import MockerFixture from onelauncher import cli, main -from onelauncher.addons.config import AddonsConfigSection from onelauncher.config_manager import ( PROGRAM_CONFIG_DEFAULT_NAME, ConfigFileError, ConfigManager, ) -from onelauncher.game_config import GameConfig, GameType, generate_game_config_id -from onelauncher.utilities import CaseInsensitiveAbsolutePath -from onelauncher.wine.config import WineConfigSection - - -@pytest.fixture -def config_dir(tmp_path: Path) -> Path: - config_dir = tmp_path / "config" - config_dir.mkdir() - return config_dir - - -@pytest.fixture -def games_dir(tmp_path: Path) -> Path: - games_dir = tmp_path / "games" - games_dir.mkdir() - return games_dir - - -@pytest.fixture -def config_manager(config_dir: Path, games_dir: Path, tmp_path: Path) -> ConfigManager: - config_manager = ConfigManager(program_config_dir=config_dir, games_dir=games_dir) - config_manager.verify_configs() - - config_manager.update_program_config_file(config_manager.read_program_config_file()) - - mock_game_dir = CaseInsensitiveAbsolutePath(tmp_path / "lotro_game_dir") - mock_game_dir.mkdir() - game_config = GameConfig( - addons=AddonsConfigSection(), - wine=WineConfigSection(), - game_type=GameType.LOTRO, - is_preview_client=False, - game_directory=mock_game_dir, - ) - config_manager.update_game_config_file( - game_id=generate_game_config_id(game_config), config=game_config - ) - - return config_manager @pytest.fixture diff --git a/tests/onelauncher/test_config_manager.py b/tests/onelauncher/test_config_manager.py index fccdee51..317c6067 100644 --- a/tests/onelauncher/test_config_manager.py +++ b/tests/onelauncher/test_config_manager.py @@ -7,6 +7,7 @@ import tomlkit import onelauncher.config_manager +from onelauncher import install_game from onelauncher.config import ConfigFieldMetadata, ConfigValWithMetadata from onelauncher.program_config import ProgramConfig @@ -172,3 +173,28 @@ def test_allow_unknown_config_keys(tmp_path: Path) -> None: onelauncher.config_manager.read_config_file( config_class=ProgramConfig, config_file_path=config_path ) + + +class TestConfigManager: + def test_delete_game_config( + self, + config_manager: onelauncher.config_manager.ConfigManager, + ) -> None: + game_id = config_manager.get_game_config_ids()[0] + game_config_dir = config_manager.get_game_config_dir(game_id) + assert next(game_config_dir.iterdir(), False) + + config_manager.delete_game_config(game_id) + assert not game_config_dir.exists() + + def test_delete_game_config_exclude_install_dir( + self, + config_manager: onelauncher.config_manager.ConfigManager, + ) -> None: + game_id, game_config = install_game.get_default_game_config( + installer=install_game.GAME_INSTALLERS[0], config_manager=config_manager + ) + config_manager.update_game_config_file(game_id=game_id, config=game_config) + game_config.game_directory.mkdir(parents=True) + config_manager.delete_game_config(game_id, exclude_install_dir=True) + assert game_config.game_directory.exists() From bd802af17cc5f6398648b995fd02d90aad166b9b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 13:31:08 -0600 Subject: [PATCH 75/97] fix(main_window): prevent game launch when patching is needed It's more broadly validating the game dir before lauch, but patching being needed is the only case that wouldn't have already halted intitialization. --- src/onelauncher/main_window.py | 79 +++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index b4b1cf22..993f07ec 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -417,6 +417,18 @@ async def start_game_button_clicked(self) -> None: self.game_cancel_scope.cancel() return + if not self.game_launcher_config: + logger.error("Game launcher network config isn't loaded") + return + + # Mainly re-checking the game dir to prevent people from starting the game + # when it's known that it needs to be patched. + try: + self.validate_game_dir() + except self.GameDirValidationError as e: + logger.exception(e.msg) + return + if self.ui.cboAccount.currentText() == "" or ( self.ui.txtPassword.text() == "" and self.ui.txtPassword.placeholderText() == "" @@ -424,10 +436,6 @@ async def start_game_button_clicked(self) -> None: logger.error("Please enter account name and password") return - if not self.game_launcher_config: - logger.error("Game launcher network config isn't loaded") - return - await self.start_game(game_launcher_config=self.game_launcher_config) def accounts_index_changed(self, new_index: int) -> None: @@ -780,35 +788,58 @@ def set_banner_image(self) -> None: ) self.ui.imgGameBanner.setPixmap(banner_pixmap) - def check_game_dir(self) -> bool: + @attrs.frozen(kw_only=True) + class GameDirValidationError(Exception): + msg: str + prevents_initialization: bool = True + + def validate_game_dir(self) -> None: + """ + Raises: + GameDirValidationError + """ game_config = self.config_manager.get_game_config(self.game_id) if not game_config.game_directory.exists(): - logger.error("Game directory not found") - return False + raise self.GameDirValidationError(msg="Game directory not found") try: if ( find_game_dir_game_type(game_config.game_directory) != game_config.game_type ): - logger.error("Game directory game type does not match config") - return False - except InvalidGameDirError: - logger.exception("Game directory is not valid") - return False + raise self.GameDirValidationError( + msg="Game directory game type does not match config" + ) + except InvalidGameDirError as e: + raise self.GameDirValidationError(msg="Game directory is not valid") from e - return True + locale = ( + game_config.locale + or self.config_manager.get_program_config().default_locale + ) + if not ( + game_config.game_directory / f"client_local_{locale.game_language_name}.dat" + ).exists(): + raise self.GameDirValidationError( + msg="The game needs to be patched. That can be done from the dropdown " + "menu on the Play button.", + prevents_initialization=False, + ) def setup_game(self) -> bool: + try: + self.validate_game_dir() + except self.GameDirValidationError as e: + if e.prevents_initialization: + logger.exception(e.msg) + return False + else: + logger.warning(e.msg, exc_info=True) + game_config = self.config_manager.get_game_config(self.game_id) launcher_config_paths = get_launcher_config_paths( search_dir=game_config.game_directory, game_type=game_config.game_type ) - if not launcher_config_paths: - # Should give error associated with there being no launcher configs - # found - self.check_game_dir() - return False try: self.game_launcher_local_config = GameLauncherLocalConfig.from_config_xml( launcher_config_paths[0].read_text(encoding="UTF-8") @@ -817,18 +848,6 @@ def setup_game(self) -> bool: logger.exception("Error parsing local launcher config") return False - locale = ( - game_config.locale - or self.config_manager.get_program_config().default_locale - ) - if not ( - game_config.game_directory / f"client_local_{locale.game_language_name}.dat" - ).exists(): - logger.warning( - "The game needs to be patched. That can be done from the dropdown " - "menu on the Play button." - ) - return True async def InitialSetup(self) -> None: From 2dccda56d4e01294e28435b96f512105c68d4cd3 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 14:11:03 -0600 Subject: [PATCH 76/97] refactor: standardize UI naming around `*_window` --- ...don_manager.py => addon_manager_window.py} | 4 +- src/onelauncher/main.py | 4 +- src/onelauncher/main_window.py | 28 +- src/onelauncher/settings_window.py | 6 +- src/onelauncher/setup_wizard.py | 6 +- src/onelauncher/ui/about.ui | 145 ---- src/onelauncher/ui/about_window.ui | 145 ++++ .../ui/{about_uic.py => about_window_uic.py} | 38 +- src/onelauncher/ui/addon_manager.ui | 717 ---------------- src/onelauncher/ui/addon_manager_window.ui | 762 +++++++++++++++++ ...ger_uic.py => addon_manager_window_uic.py} | 112 +-- src/onelauncher/ui/error_message.ui | 90 -- src/onelauncher/ui/error_message_window.ui | 90 ++ ...age_uic.py => error_message_window_uic.py} | 36 +- src/onelauncher/ui/install_game.ui | 151 ---- ...install_game.py => install_game_window.py} | 4 +- src/onelauncher/ui/install_game_window.ui | 153 ++++ ...game_uic.py => install_game_window_uic.py} | 48 +- src/onelauncher/ui/log_window.ui | 80 -- src/onelauncher/ui/log_window_uic.py | 55 -- src/onelauncher/ui/main.ui | 557 ------------ src/onelauncher/ui/main_window.ui | 570 +++++++++++++ .../ui/{main_uic.py => main_window_uic.py} | 106 +-- src/onelauncher/ui/patch_game.ui | 63 -- .../{patch_game.py => patch_game_window.py} | 4 +- src/onelauncher/ui/patch_game_window.ui | 63 ++ ...h_game_uic.py => patch_game_window_uic.py} | 38 +- src/onelauncher/ui/select_subscription.ui | 91 -- .../ui/select_subscription_window.ui | 92 ++ ...c.py => select_subscription_window_uic.py} | 36 +- src/onelauncher/ui/settings.ui | 769 ----------------- src/onelauncher/ui/settings_window.ui | 804 ++++++++++++++++++ ...settings_uic.py => settings_window_uic.py} | 142 ++-- src/onelauncher/ui/setup_wizard.ui | 322 ------- src/onelauncher/ui/setup_wizard_window.ui | 329 +++++++ ...zard_uic.py => setup_wizard_window_uic.py} | 80 +- 36 files changed, 3354 insertions(+), 3386 deletions(-) rename src/onelauncher/{addon_manager.py => addon_manager_window.py} (99%) delete mode 100644 src/onelauncher/ui/about.ui create mode 100644 src/onelauncher/ui/about_window.ui rename src/onelauncher/ui/{about_uic.py => about_window_uic.py} (75%) delete mode 100644 src/onelauncher/ui/addon_manager.ui create mode 100644 src/onelauncher/ui/addon_manager_window.ui rename src/onelauncher/ui/{addon_manager_uic.py => addon_manager_window_uic.py} (83%) delete mode 100644 src/onelauncher/ui/error_message.ui create mode 100644 src/onelauncher/ui/error_message_window.ui rename src/onelauncher/ui/{error_message_uic.py => error_message_window_uic.py} (61%) delete mode 100644 src/onelauncher/ui/install_game.ui rename src/onelauncher/ui/{install_game.py => install_game_window.py} (98%) create mode 100644 src/onelauncher/ui/install_game_window.ui rename src/onelauncher/ui/{install_game_uic.py => install_game_window_uic.py} (76%) delete mode 100644 src/onelauncher/ui/log_window.ui delete mode 100644 src/onelauncher/ui/log_window_uic.py delete mode 100644 src/onelauncher/ui/main.ui create mode 100644 src/onelauncher/ui/main_window.ui rename src/onelauncher/ui/{main_uic.py => main_window_uic.py} (79%) delete mode 100644 src/onelauncher/ui/patch_game.ui rename src/onelauncher/ui/{patch_game.py => patch_game_window.py} (98%) create mode 100644 src/onelauncher/ui/patch_game_window.ui rename src/onelauncher/ui/{patch_game_uic.py => patch_game_window_uic.py} (61%) delete mode 100644 src/onelauncher/ui/select_subscription.ui create mode 100644 src/onelauncher/ui/select_subscription_window.ui rename src/onelauncher/ui/{select_subscription_uic.py => select_subscription_window_uic.py} (58%) delete mode 100644 src/onelauncher/ui/settings.ui create mode 100644 src/onelauncher/ui/settings_window.ui rename src/onelauncher/ui/{settings_uic.py => settings_window_uic.py} (83%) delete mode 100644 src/onelauncher/ui/setup_wizard.ui create mode 100644 src/onelauncher/ui/setup_wizard_window.ui rename src/onelauncher/ui/{setup_wizard_uic.py => setup_wizard_window_uic.py} (77%) diff --git a/src/onelauncher/addon_manager.py b/src/onelauncher/addon_manager_window.py similarity index 99% rename from src/onelauncher/addon_manager.py rename to src/onelauncher/addon_manager_window.py index 56f0737b..b0044400 100644 --- a/src/onelauncher/addon_manager.py +++ b/src/onelauncher/addon_manager_window.py @@ -71,7 +71,7 @@ from .game_launcher_local_config import GameLauncherLocalConfig from .game_utilities import get_game_settings_dir from .network.httpx_client import get_httpx_client_sync -from .ui.addon_manager_uic import Ui_winAddonManager +from .ui.addon_manager_window_uic import Ui_addonManagerWindow from .ui.qtapp import get_qapp from .ui.qtdesigner.custom_widgets import QWidgetWithStylePreview from .utilities import CaseInsensitiveAbsolutePath @@ -221,7 +221,7 @@ def __init__( super().__init__() self.config_manager = config_manager self.game_id: GameConfigID = game_id - self.ui = Ui_winAddonManager() + self.ui = Ui_addonManagerWindow() self.ui.setupUi(self) game_config = self.config_manager.get_game_config(self.game_id) diff --git a/src/onelauncher/main.py b/src/onelauncher/main.py index 8291af49..8ad0afb5 100644 --- a/src/onelauncher/main.py +++ b/src/onelauncher/main.py @@ -13,7 +13,7 @@ from .game_config import GameConfigID from .main_window import MainWindow from .setup_wizard import SetupWizard -from .ui.error_message_uic import Ui_errorDialog +from .ui.error_message_window_uic import Ui_errorMessageWindow from .ui.qtapp import get_qapp logger = logging.getLogger(__name__) @@ -29,7 +29,7 @@ def show_invalid_config_dialog( """ _ = get_qapp() dialog = QtWidgets.QDialog() - ui = Ui_errorDialog() + ui = Ui_errorMessageWindow() ui.setupUi(dialog) ui.textLabel.setText(error.msg) ui.detailsTextEdit.setPlainText(traceback.format_exc()) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 993f07ec..f7adc14b 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -42,7 +42,7 @@ from typing_extensions import override from xmlschema import XMLSchemaValidationError -from . import __about__, addon_manager +from . import __about__, addon_manager_window from .addons.startup_script import run_startup_script from .config_manager import ConfigManager, NoValidGamesError from .game_account_config import GameAccountConfig @@ -76,12 +76,12 @@ from .resources import get_resource from .settings_window import SettingsWindow from .start_game import MissingLaunchArgumentError, start_game -from .ui.about_uic import Ui_dlgAbout +from .ui.about_window_uic import Ui_aboutWindow from .ui.custom_widgets import FramelessQMainWindowWithStylePreview -from .ui.main_uic import Ui_winMain -from .ui.patch_game import PatchGameWindow +from .ui.main_window_uic import Ui_mainWindow +from .ui.patch_game_window import PatchGameWindow from .ui.qtapp import get_app_style, get_qapp -from .ui.select_subscription_uic import Ui_dlgSelectSubscription +from .ui.select_subscription_window_uic import Ui_selectSubscriptionWindow from .ui.utilities import log_record_to_rich_text, show_message_box_details_as_markdown logger = logging.getLogger(__name__) @@ -101,7 +101,7 @@ def __init__( self.network_setup_nursery: trio.Nursery | None = None self.game_cancel_scope: trio.CancelScope | None = None - self.addon_manager_window: addon_manager.AddonManagerWindow | None = None + self.addon_manager_window: addon_manager_window.AddonManagerWindow | None = None self.game_launcher_config: GameLauncherConfig | None = None def addon_manager_error_log(self, record: logging.LogRecord) -> None: @@ -110,7 +110,7 @@ def addon_manager_error_log(self, record: logging.LogRecord) -> None: self.activateWindow() def setup_ui(self) -> None: - self.ui = Ui_winMain() + self.ui = Ui_mainWindow() self.ui.setupUi(self) logger.addHandler( @@ -121,7 +121,7 @@ def setup_ui(self) -> None: level=logging.INFO, ) ) - addon_manager.logger.addHandler( + addon_manager_window.logger.addHandler( ForwardLogsHandler( new_log_callback=self.addon_manager_error_log, level=logging.INFO ) @@ -309,10 +309,10 @@ def setup_switch_game_button(self) -> None: self.ui.btnSwitchGame.setEnabled(True) def btnAboutSelected(self) -> None: - dlgAbout = QtWidgets.QDialog(self, QtCore.Qt.WindowType.Popup) + about_window = QtWidgets.QDialog(self, QtCore.Qt.WindowType.Popup) - ui = Ui_dlgAbout() - ui.setupUi(dlgAbout) + ui = Ui_aboutWindow() + ui.setupUi(about_window) ui.lblDescription.setText(__about__.__description__) if __about__.__project_url__: @@ -325,7 +325,7 @@ def btnAboutSelected(self) -> None: ui.lblVersion.setText(f"Version: {__about__.__version__}") ui.lblCopyrightHistory.setText(__about__.__copyright_history__) - dlgAbout.exec() + about_window.exec() self.resetFocus() async def actionPatchSelected(self) -> None: @@ -361,7 +361,7 @@ def btnAddonManagerSelected(self) -> None: else: self.addon_manager_window.deleteLater() - self.addon_manager_window = addon_manager.AddonManagerWindow( + self.addon_manager_window = addon_manager_window.AddonManagerWindow( config_manager=self.config_manager, game_id=self.game_id, launcher_local_config=self.game_launcher_local_config, @@ -526,7 +526,7 @@ def get_game_subscription_selection( select_subscription_dialog = QtWidgets.QDialog( self, QtCore.Qt.WindowType.FramelessWindowHint ) - ui = Ui_dlgSelectSubscription() + ui = Ui_selectSubscriptionWindow() ui.setupUi(select_subscription_dialog) for subscription in subscriptions: diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 81cd99fb..7c9c04b4 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -58,7 +58,7 @@ from .standard_game_launcher import get_standard_game_launcher_path from .ui.custom_widgets import FramelessQDialogWithStylePreview from .ui.qtapp import get_qapp -from .ui.settings_uic import Ui_dlgSettings +from .ui.settings_window_uic import Ui_settingsWindow from .ui.utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath from .wine_environment import get_wine_process_args @@ -79,7 +79,7 @@ def __init__(self, config_manager: ConfigManager, game_id: GameConfigID): self.titleBar.hide() self.config_manager = config_manager self.game_id = game_id - self.ui = Ui_dlgSettings() + self.ui = Ui_settingsWindow() self.ui.setupUi(self) def setup_ui(self) -> None: @@ -199,7 +199,7 @@ def setup_ui(self) -> None: @override def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: - """Lets the user drag the window when left-click holding it""" + # Let the user drag the window when left-click holding it. if event.button() == QtCore.Qt.MouseButton.LeftButton: self.windowHandle().startSystemMove() event.accept() diff --git a/src/onelauncher/setup_wizard.py b/src/onelauncher/setup_wizard.py index 1201c066..9b63727a 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -62,9 +62,9 @@ from .official_clients import get_game_icon, is_gls_url_for_preview_client from .program_config import GamesSortingMode, ProgramConfig from .resources import available_locales -from .ui.install_game import InstallGameWindow +from .ui.install_game_window import InstallGameWindow from .ui.qtapp import get_app_style, get_qapp -from .ui.setup_wizard_uic import Ui_Wizard +from .ui.setup_wizard_window_uic import Ui_setupWizardWindow from .ui.utilities import show_warning_message from .utilities import CaseInsensitiveAbsolutePath from .v1x_config_migrator import ( @@ -125,7 +125,7 @@ def __init__( self.game_selection_only = game_selection_only self.select_existing_games = select_existing_games - self.ui = Ui_Wizard() + self.ui = Ui_setupWizardWindow() self.ui.setupUi(self) self.setWindowTitle("Setup Wizard") diff --git a/src/onelauncher/ui/about.ui b/src/onelauncher/ui/about.ui deleted file mode 100644 index 29cd4e55..00000000 --- a/src/onelauncher/ui/about.ui +++ /dev/null @@ -1,145 +0,0 @@ - - - dlgAbout - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 400 - 250 - - - - About - - - true - - - - - - 9 - - - 12 - - - 12 - - - 12 - - - 12 - - - - - - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - Qt::AlignmentFlag::AlignCenter - - - true - - - Qt::TextInteractionFlag::TextBrowserInteraction - - - - - - - - - - Qt::AlignmentFlag::AlignCenter - - - - - - - false - - - - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - - - Qt::AlignmentFlag::AlignCenter - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - Qt::Orientation::Horizontal - - - QDialogButtonBox::StandardButton::Close - - - - - - - - - buttonBox - clicked(QAbstractButton*) - dlgAbout - accept() - - - 259 - 273 - - - 259 - 149 - - - - - diff --git a/src/onelauncher/ui/about_window.ui b/src/onelauncher/ui/about_window.ui new file mode 100644 index 00000000..3bc80c73 --- /dev/null +++ b/src/onelauncher/ui/about_window.ui @@ -0,0 +1,145 @@ + + + aboutWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 400 + 250 + + + + About + + + true + + + + + + 9 + + + 12 + + + 12 + + + 12 + + + 12 + + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + Qt::AlignmentFlag::AlignCenter + + + true + + + Qt::TextInteractionFlag::TextBrowserInteraction + + + + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + false + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Close + + + + + + + + + buttonBox + clicked(QAbstractButton*) + aboutWindow + accept() + + + 259 + 273 + + + 259 + 149 + + + + + \ No newline at end of file diff --git a/src/onelauncher/ui/about_uic.py b/src/onelauncher/ui/about_window_uic.py similarity index 75% rename from src/onelauncher/ui/about_uic.py rename to src/onelauncher/ui/about_window_uic.py index f8cf5b08..890ba51d 100644 --- a/src/onelauncher/ui/about_uic.py +++ b/src/onelauncher/ui/about_window_uic.py @@ -19,26 +19,26 @@ QLabel, QSizePolicy, QSpacerItem, QVBoxLayout, QWidget) -class Ui_dlgAbout(object): - def setupUi(self, dlgAbout: QDialog) -> None: - if not dlgAbout.objectName(): - dlgAbout.setObjectName(u"dlgAbout") - dlgAbout.setWindowModality(Qt.WindowModality.ApplicationModal) - dlgAbout.resize(400, 250) - dlgAbout.setModal(True) - self.verticalLayout_2 = QVBoxLayout(dlgAbout) +class Ui_aboutWindow(object): + def setupUi(self, aboutWindow: QDialog) -> None: + if not aboutWindow.objectName(): + aboutWindow.setObjectName(u"aboutWindow") + aboutWindow.setWindowModality(Qt.WindowModality.ApplicationModal) + aboutWindow.resize(400, 250) + aboutWindow.setModal(True) + self.verticalLayout_2 = QVBoxLayout(aboutWindow) self.verticalLayout_2.setObjectName(u"verticalLayout_2") self.verticalLayout = QVBoxLayout() self.verticalLayout.setSpacing(9) self.verticalLayout.setObjectName(u"verticalLayout") self.verticalLayout.setContentsMargins(12, 12, 12, 12) - self.lblDescription = QLabel(dlgAbout) + self.lblDescription = QLabel(aboutWindow) self.lblDescription.setObjectName(u"lblDescription") self.lblDescription.setAlignment(Qt.AlignmentFlag.AlignCenter) self.verticalLayout.addWidget(self.lblDescription) - self.lblRepoWebsite = QLabel(dlgAbout) + self.lblRepoWebsite = QLabel(aboutWindow) self.lblRepoWebsite.setObjectName(u"lblRepoWebsite") self.lblRepoWebsite.setAlignment(Qt.AlignmentFlag.AlignCenter) self.lblRepoWebsite.setOpenExternalLinks(True) @@ -46,20 +46,20 @@ def setupUi(self, dlgAbout: QDialog) -> None: self.verticalLayout.addWidget(self.lblRepoWebsite) - self.lblCopyright = QLabel(dlgAbout) + self.lblCopyright = QLabel(aboutWindow) self.lblCopyright.setObjectName(u"lblCopyright") self.lblCopyright.setAlignment(Qt.AlignmentFlag.AlignCenter) self.verticalLayout.addWidget(self.lblCopyright) - self.lblCopyrightHistory = QLabel(dlgAbout) + self.lblCopyrightHistory = QLabel(aboutWindow) self.lblCopyrightHistory.setObjectName(u"lblCopyrightHistory") self.lblCopyrightHistory.setAcceptDrops(False) self.lblCopyrightHistory.setAlignment(Qt.AlignmentFlag.AlignCenter) self.verticalLayout.addWidget(self.lblCopyrightHistory) - self.lblVersion = QLabel(dlgAbout) + self.lblVersion = QLabel(aboutWindow) self.lblVersion.setObjectName(u"lblVersion") self.lblVersion.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -72,7 +72,7 @@ def setupUi(self, dlgAbout: QDialog) -> None: self.verticalLayout_2.addLayout(self.verticalLayout) - self.buttonBox = QDialogButtonBox(dlgAbout) + self.buttonBox = QDialogButtonBox(aboutWindow) self.buttonBox.setObjectName(u"buttonBox") self.buttonBox.setOrientation(Qt.Orientation.Horizontal) self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close) @@ -80,14 +80,14 @@ def setupUi(self, dlgAbout: QDialog) -> None: self.verticalLayout_2.addWidget(self.buttonBox) - self.retranslateUi(dlgAbout) - self.buttonBox.clicked.connect(dlgAbout.accept) + self.retranslateUi(aboutWindow) + self.buttonBox.clicked.connect(aboutWindow.accept) - QMetaObject.connectSlotsByName(dlgAbout) + QMetaObject.connectSlotsByName(aboutWindow) # setupUi - def retranslateUi(self, dlgAbout: QDialog) -> None: - dlgAbout.setWindowTitle(QCoreApplication.translate("dlgAbout", u"About", None)) + def retranslateUi(self, aboutWindow: QDialog) -> None: + aboutWindow.setWindowTitle(QCoreApplication.translate("aboutWindow", u"About", None)) self.lblDescription.setText("") self.lblRepoWebsite.setText("") self.lblCopyright.setText("") diff --git a/src/onelauncher/ui/addon_manager.ui b/src/onelauncher/ui/addon_manager.ui deleted file mode 100644 index 1955eec2..00000000 --- a/src/onelauncher/ui/addon_manager.ui +++ /dev/null @@ -1,717 +0,0 @@ - - - winAddonManager - - - - 0 - 0 - 720 - 400 - - - - Addons Manager - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 9 - - - 9 - - - 9 - - - 9 - - - 9 - - - - - Search here - - - true - - - - - - - Remove addons - - - QToolButton::ToolButtonPopupMode::MenuButtonPopup - - - - icon-lg - px-2.5 - py-1 - - - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 6 - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - 6 - - - 6 - - - 6 - - - 6 - - - 6 - - - - - - 0 - 0 - - - - Update all addons - - - Update All - - - false - - - - - - - Check for updates - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - QFrame::Shape::NoFrame - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::EditTrigger::NoEditTriggers - - - false - - - QAbstractItemView::SelectionMode::MultiSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - true - - - true - - - true - - - false - - - true - - - false - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::Shape::NoFrame - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::EditTrigger::NoEditTriggers - - - false - - - QAbstractItemView::SelectionMode::MultiSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - true - - - true - - - true - - - false - - - true - - - false - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::Shape::NoFrame - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::EditTrigger::NoEditTriggers - - - false - - - QAbstractItemView::SelectionMode::MultiSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - true - - - true - - - true - - - false - - - true - - - false - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - 6 - - - 6 - - - 6 - - - 6 - - - - - Check for updates - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::Shape::NoFrame - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::EditTrigger::NoEditTriggers - - - false - - - QAbstractItemView::SelectionMode::MultiSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - true - - - true - - - true - - - false - - - true - - - false - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::Shape::NoFrame - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::EditTrigger::NoEditTriggers - - - false - - - QAbstractItemView::SelectionMode::MultiSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - true - - - true - - - true - - - false - - - true - - - false - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QFrame::Shape::NoFrame - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QAbstractItemView::EditTrigger::NoEditTriggers - - - false - - - QAbstractItemView::SelectionMode::MultiSelection - - - QAbstractItemView::SelectionBehavior::SelectRows - - - true - - - true - - - true - - - false - - - true - - - false - - - - - - - - - - - - - - - false - - - - max-h-2 - - - - - - - - Import Addons - - - Import addons from files/archives - - - Ctrl+I - - - - - Show on lotrointerface.com - - - - - Show selected addons on lotrointerface.com - - - - - Install - - - - - Uninstall - - - - - Show in file manager - - - - - Show plugins folder in file manager - - - - - Show skins folder in file manager - - - - - Show music folder in file manager - - - - - Update selected addons - - - - - Update - - - - - Enable startup script - - - - - Disable startup script - - - - - Show selected addons in file manager - - - Show selected addons in file manager - - - - - - QWidgetWithStylePreview - QWidget -
.qtdesigner.custom_widgets
- 1 -
- - NoOddSizesQToolButton - QToolButton -
.custom_widgets
-
- - QTabBar - QWidget -
qtabbar.h
- 1 -
-
- - txtSearchBar - tablePluginsInstalled - tableSkinsInstalled - tableMusicInstalled - tablePlugins - tableSkins - tableMusic - btnAddons - btnUpdateAll - btnCheckForUpdates - btnCheckForUpdates_2 - - - -
diff --git a/src/onelauncher/ui/addon_manager_window.ui b/src/onelauncher/ui/addon_manager_window.ui new file mode 100644 index 00000000..3504a2f0 --- /dev/null +++ b/src/onelauncher/ui/addon_manager_window.ui @@ -0,0 +1,762 @@ + + + addonManagerWindow + + + + 0 + 0 + 720 + 400 + + + + Addons Manager + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 9 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Search here + + + true + + + + + + + Remove addons + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + + icon-lg + px-2.5 + py-1 + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 6 + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + 6 + + + 6 + + + 6 + + + 6 + + + 6 + + + + + + 0 + 0 + + + + Update all addons + + + Update All + + + false + + + + + + + Check for updates + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + QFrame::Shape::NoFrame + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + QAbstractItemView::SelectionMode::MultiSelection + + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + true + + + true + + + false + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::Shape::NoFrame + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + QAbstractItemView::SelectionMode::MultiSelection + + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + true + + + true + + + false + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::Shape::NoFrame + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + QAbstractItemView::SelectionMode::MultiSelection + + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + true + + + true + + + false + + + true + + + false + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + 6 + + + 6 + + + 6 + + + 6 + + + + + Check for updates + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::Shape::NoFrame + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + QAbstractItemView::SelectionMode::MultiSelection + + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + true + + + true + + + false + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::Shape::NoFrame + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + QAbstractItemView::SelectionMode::MultiSelection + + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + true + + + true + + + false + + + true + + + false + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::Shape::NoFrame + + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + false + + + + QAbstractItemView::SelectionMode::MultiSelection + + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + true + + + true + + + false + + + true + + + false + + + + + + + + + + + + + + + false + + + + max-h-2 + + + + + + + + Import Addons + + + Import addons from files/archives + + + Ctrl+I + + + + + Show on lotrointerface.com + + + + + Show selected addons on lotrointerface.com + + + + + Install + + + + + Uninstall + + + + + Show in file manager + + + + + Show plugins folder in file manager + + + + + Show skins folder in file manager + + + + + Show music folder in file manager + + + + + Update selected addons + + + + + Update + + + + + Enable startup script + + + + + Disable startup script + + + + + Show selected addons in file manager + + + Show selected addons in file manager + + + + + + QWidgetWithStylePreview + QWidget +
.qtdesigner.custom_widgets
+ 1 +
+ + NoOddSizesQToolButton + QToolButton +
.custom_widgets
+
+ + QTabBar + QWidget +
qtabbar.h
+ 1 +
+
+ + txtSearchBar + tablePluginsInstalled + tableSkinsInstalled + tableMusicInstalled + tablePlugins + tableSkins + tableMusic + btnAddons + btnUpdateAll + btnCheckForUpdates + btnCheckForUpdates_2 + + + +
\ No newline at end of file diff --git a/src/onelauncher/ui/addon_manager_uic.py b/src/onelauncher/ui/addon_manager_window_uic.py similarity index 83% rename from src/onelauncher/ui/addon_manager_uic.py rename to src/onelauncher/ui/addon_manager_window_uic.py index 062e8fce..69b98b90 100644 --- a/src/onelauncher/ui/addon_manager_uic.py +++ b/src/onelauncher/ui/addon_manager_window_uic.py @@ -25,40 +25,40 @@ from .custom_widgets import NoOddSizesQToolButton from .qtdesigner.custom_widgets import QWidgetWithStylePreview -class Ui_winAddonManager(object): - def setupUi(self, winAddonManager: QWidgetWithStylePreview) -> None: - if not winAddonManager.objectName(): - winAddonManager.setObjectName(u"winAddonManager") - winAddonManager.resize(720, 400) - self.actionAddonImport = QAction(winAddonManager) +class Ui_addonManagerWindow(object): + def setupUi(self, addonManagerWindow: QWidgetWithStylePreview) -> None: + if not addonManagerWindow.objectName(): + addonManagerWindow.setObjectName(u"addonManagerWindow") + addonManagerWindow.resize(720, 400) + self.actionAddonImport = QAction(addonManagerWindow) self.actionAddonImport.setObjectName(u"actionAddonImport") - self.actionShowOnLotrointerface = QAction(winAddonManager) + self.actionShowOnLotrointerface = QAction(addonManagerWindow) self.actionShowOnLotrointerface.setObjectName(u"actionShowOnLotrointerface") - self.actionShowSelectedOnLotrointerface = QAction(winAddonManager) + self.actionShowSelectedOnLotrointerface = QAction(addonManagerWindow) self.actionShowSelectedOnLotrointerface.setObjectName(u"actionShowSelectedOnLotrointerface") - self.actionInstallAddon = QAction(winAddonManager) + self.actionInstallAddon = QAction(addonManagerWindow) self.actionInstallAddon.setObjectName(u"actionInstallAddon") - self.actionUninstallAddon = QAction(winAddonManager) + self.actionUninstallAddon = QAction(addonManagerWindow) self.actionUninstallAddon.setObjectName(u"actionUninstallAddon") - self.actionShowAddonInFileManager = QAction(winAddonManager) + self.actionShowAddonInFileManager = QAction(addonManagerWindow) self.actionShowAddonInFileManager.setObjectName(u"actionShowAddonInFileManager") - self.actionShowPluginsFolderInFileManager = QAction(winAddonManager) + self.actionShowPluginsFolderInFileManager = QAction(addonManagerWindow) self.actionShowPluginsFolderInFileManager.setObjectName(u"actionShowPluginsFolderInFileManager") - self.actionShowSkinsFolderInFileManager = QAction(winAddonManager) + self.actionShowSkinsFolderInFileManager = QAction(addonManagerWindow) self.actionShowSkinsFolderInFileManager.setObjectName(u"actionShowSkinsFolderInFileManager") - self.actionShowMusicFolderInFileManager = QAction(winAddonManager) + self.actionShowMusicFolderInFileManager = QAction(addonManagerWindow) self.actionShowMusicFolderInFileManager.setObjectName(u"actionShowMusicFolderInFileManager") - self.actionUpdateSelectedAddons = QAction(winAddonManager) + self.actionUpdateSelectedAddons = QAction(addonManagerWindow) self.actionUpdateSelectedAddons.setObjectName(u"actionUpdateSelectedAddons") - self.actionUpdateAddon = QAction(winAddonManager) + self.actionUpdateAddon = QAction(addonManagerWindow) self.actionUpdateAddon.setObjectName(u"actionUpdateAddon") - self.actionEnableStartupScript = QAction(winAddonManager) + self.actionEnableStartupScript = QAction(addonManagerWindow) self.actionEnableStartupScript.setObjectName(u"actionEnableStartupScript") - self.actionDisableStartupScript = QAction(winAddonManager) + self.actionDisableStartupScript = QAction(addonManagerWindow) self.actionDisableStartupScript.setObjectName(u"actionDisableStartupScript") - self.actionShowSelectedAddonsInFileManager = QAction(winAddonManager) + self.actionShowSelectedAddonsInFileManager = QAction(addonManagerWindow) self.actionShowSelectedAddonsInFileManager.setObjectName(u"actionShowSelectedAddonsInFileManager") - self.verticalLayout_9 = QVBoxLayout(winAddonManager) + self.verticalLayout_9 = QVBoxLayout(addonManagerWindow) self.verticalLayout_9.setSpacing(0) self.verticalLayout_9.setObjectName(u"verticalLayout_9") self.verticalLayout_9.setContentsMargins(0, 0, 0, 0) @@ -66,13 +66,13 @@ def setupUi(self, winAddonManager: QWidgetWithStylePreview) -> None: self.horizontalLayout_3.setSpacing(9) self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") self.horizontalLayout_3.setContentsMargins(9, 9, 9, 9) - self.txtSearchBar = QLineEdit(winAddonManager) + self.txtSearchBar = QLineEdit(addonManagerWindow) self.txtSearchBar.setObjectName(u"txtSearchBar") self.txtSearchBar.setClearButtonEnabled(True) self.horizontalLayout_3.addWidget(self.txtSearchBar) - self.btnAddons = NoOddSizesQToolButton(winAddonManager) + self.btnAddons = NoOddSizesQToolButton(addonManagerWindow) self.btnAddons.setObjectName(u"btnAddons") self.btnAddons.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) @@ -81,12 +81,12 @@ def setupUi(self, winAddonManager: QWidgetWithStylePreview) -> None: self.verticalLayout_9.addLayout(self.horizontalLayout_3) - self.tabBarSource = QTabBar(winAddonManager) + self.tabBarSource = QTabBar(addonManagerWindow) self.tabBarSource.setObjectName(u"tabBarSource") self.verticalLayout_9.addWidget(self.tabBarSource) - self.stackedWidgetSource = QStackedWidget(winAddonManager) + self.stackedWidgetSource = QStackedWidget(addonManagerWindow) self.stackedWidgetSource.setObjectName(u"stackedWidgetSource") self.pageInstalled = QWidget() self.pageInstalled.setObjectName(u"pageInstalled") @@ -324,7 +324,7 @@ def setupUi(self, winAddonManager: QWidgetWithStylePreview) -> None: self.verticalLayout_9.addWidget(self.stackedWidgetSource) - self.progressBar = QProgressBar(winAddonManager) + self.progressBar = QProgressBar(addonManagerWindow) self.progressBar.setObjectName(u"progressBar") self.progressBar.setTextVisible(False) @@ -341,55 +341,55 @@ def setupUi(self, winAddonManager: QWidgetWithStylePreview) -> None: QWidget.setTabOrder(self.btnUpdateAll, self.btnCheckForUpdates) QWidget.setTabOrder(self.btnCheckForUpdates, self.btnCheckForUpdates_2) - self.retranslateUi(winAddonManager) + self.retranslateUi(addonManagerWindow) - QMetaObject.connectSlotsByName(winAddonManager) + QMetaObject.connectSlotsByName(addonManagerWindow) # setupUi - def retranslateUi(self, winAddonManager: QWidgetWithStylePreview) -> None: - winAddonManager.setWindowTitle(QCoreApplication.translate("winAddonManager", u"Addons Manager", None)) - self.actionAddonImport.setText(QCoreApplication.translate("winAddonManager", u"Import Addons", None)) + def retranslateUi(self, addonManagerWindow: QWidgetWithStylePreview) -> None: + addonManagerWindow.setWindowTitle(QCoreApplication.translate("addonManagerWindow", u"Addons Manager", None)) + self.actionAddonImport.setText(QCoreApplication.translate("addonManagerWindow", u"Import Addons", None)) #if QT_CONFIG(tooltip) - self.actionAddonImport.setToolTip(QCoreApplication.translate("winAddonManager", u"Import addons from files/archives", None)) + self.actionAddonImport.setToolTip(QCoreApplication.translate("addonManagerWindow", u"Import addons from files/archives", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(shortcut) - self.actionAddonImport.setShortcut(QCoreApplication.translate("winAddonManager", u"Ctrl+I", None)) + self.actionAddonImport.setShortcut(QCoreApplication.translate("addonManagerWindow", u"Ctrl+I", None)) #endif // QT_CONFIG(shortcut) - self.actionShowOnLotrointerface.setText(QCoreApplication.translate("winAddonManager", u"Show on lotrointerface.com", None)) - self.actionShowSelectedOnLotrointerface.setText(QCoreApplication.translate("winAddonManager", u"Show selected addons on lotrointerface.com", None)) - self.actionInstallAddon.setText(QCoreApplication.translate("winAddonManager", u"Install", None)) - self.actionUninstallAddon.setText(QCoreApplication.translate("winAddonManager", u"Uninstall", None)) - self.actionShowAddonInFileManager.setText(QCoreApplication.translate("winAddonManager", u"Show in file manager", None)) - self.actionShowPluginsFolderInFileManager.setText(QCoreApplication.translate("winAddonManager", u"Show plugins folder in file manager", None)) - self.actionShowSkinsFolderInFileManager.setText(QCoreApplication.translate("winAddonManager", u"Show skins folder in file manager", None)) - self.actionShowMusicFolderInFileManager.setText(QCoreApplication.translate("winAddonManager", u"Show music folder in file manager", None)) - self.actionUpdateSelectedAddons.setText(QCoreApplication.translate("winAddonManager", u"Update selected addons", None)) - self.actionUpdateAddon.setText(QCoreApplication.translate("winAddonManager", u"Update", None)) - self.actionEnableStartupScript.setText(QCoreApplication.translate("winAddonManager", u"Enable startup script", None)) - self.actionDisableStartupScript.setText(QCoreApplication.translate("winAddonManager", u"Disable startup script", None)) - self.actionShowSelectedAddonsInFileManager.setText(QCoreApplication.translate("winAddonManager", u"Show selected addons in file manager", None)) + self.actionShowOnLotrointerface.setText(QCoreApplication.translate("addonManagerWindow", u"Show on lotrointerface.com", None)) + self.actionShowSelectedOnLotrointerface.setText(QCoreApplication.translate("addonManagerWindow", u"Show selected addons on lotrointerface.com", None)) + self.actionInstallAddon.setText(QCoreApplication.translate("addonManagerWindow", u"Install", None)) + self.actionUninstallAddon.setText(QCoreApplication.translate("addonManagerWindow", u"Uninstall", None)) + self.actionShowAddonInFileManager.setText(QCoreApplication.translate("addonManagerWindow", u"Show in file manager", None)) + self.actionShowPluginsFolderInFileManager.setText(QCoreApplication.translate("addonManagerWindow", u"Show plugins folder in file manager", None)) + self.actionShowSkinsFolderInFileManager.setText(QCoreApplication.translate("addonManagerWindow", u"Show skins folder in file manager", None)) + self.actionShowMusicFolderInFileManager.setText(QCoreApplication.translate("addonManagerWindow", u"Show music folder in file manager", None)) + self.actionUpdateSelectedAddons.setText(QCoreApplication.translate("addonManagerWindow", u"Update selected addons", None)) + self.actionUpdateAddon.setText(QCoreApplication.translate("addonManagerWindow", u"Update", None)) + self.actionEnableStartupScript.setText(QCoreApplication.translate("addonManagerWindow", u"Enable startup script", None)) + self.actionDisableStartupScript.setText(QCoreApplication.translate("addonManagerWindow", u"Disable startup script", None)) + self.actionShowSelectedAddonsInFileManager.setText(QCoreApplication.translate("addonManagerWindow", u"Show selected addons in file manager", None)) #if QT_CONFIG(tooltip) - self.actionShowSelectedAddonsInFileManager.setToolTip(QCoreApplication.translate("winAddonManager", u"Show selected addons in file manager", None)) + self.actionShowSelectedAddonsInFileManager.setToolTip(QCoreApplication.translate("addonManagerWindow", u"Show selected addons in file manager", None)) #endif // QT_CONFIG(tooltip) - self.txtSearchBar.setPlaceholderText(QCoreApplication.translate("winAddonManager", u"Search here", None)) + self.txtSearchBar.setPlaceholderText(QCoreApplication.translate("addonManagerWindow", u"Search here", None)) #if QT_CONFIG(tooltip) - self.btnAddons.setToolTip(QCoreApplication.translate("winAddonManager", u"Remove addons", None)) + self.btnAddons.setToolTip(QCoreApplication.translate("addonManagerWindow", u"Remove addons", None)) #endif // QT_CONFIG(tooltip) self.btnAddons.setProperty(u"qssClass", [ - QCoreApplication.translate("winAddonManager", u"icon-lg", None), - QCoreApplication.translate("winAddonManager", u"px-2.5", None), - QCoreApplication.translate("winAddonManager", u"py-1", None)]) + QCoreApplication.translate("addonManagerWindow", u"icon-lg", None), + QCoreApplication.translate("addonManagerWindow", u"px-2.5", None), + QCoreApplication.translate("addonManagerWindow", u"py-1", None)]) #if QT_CONFIG(tooltip) - self.btnUpdateAll.setToolTip(QCoreApplication.translate("winAddonManager", u"Update all addons", None)) + self.btnUpdateAll.setToolTip(QCoreApplication.translate("addonManagerWindow", u"Update all addons", None)) #endif // QT_CONFIG(tooltip) - self.btnUpdateAll.setText(QCoreApplication.translate("winAddonManager", u"Update All", None)) + self.btnUpdateAll.setText(QCoreApplication.translate("addonManagerWindow", u"Update All", None)) #if QT_CONFIG(tooltip) - self.btnCheckForUpdates.setToolTip(QCoreApplication.translate("winAddonManager", u"Check for updates", None)) + self.btnCheckForUpdates.setToolTip(QCoreApplication.translate("addonManagerWindow", u"Check for updates", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.btnCheckForUpdates_2.setToolTip(QCoreApplication.translate("winAddonManager", u"Check for updates", None)) + self.btnCheckForUpdates_2.setToolTip(QCoreApplication.translate("addonManagerWindow", u"Check for updates", None)) #endif // QT_CONFIG(tooltip) self.progressBar.setProperty(u"qssClass", [ - QCoreApplication.translate("winAddonManager", u"max-h-2", None)]) + QCoreApplication.translate("addonManagerWindow", u"max-h-2", None)]) # retranslateUi diff --git a/src/onelauncher/ui/error_message.ui b/src/onelauncher/ui/error_message.ui deleted file mode 100644 index e9d8843c..00000000 --- a/src/onelauncher/ui/error_message.ui +++ /dev/null @@ -1,90 +0,0 @@ - - - errorDialog - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 400 - 300 - - - - Error - - - true - - - - - - Error: - - - - - - - false - - - QPlainTextEdit::LineWrapMode::NoWrap - - - true - - - - - - - Qt::Orientation::Horizontal - - - QDialogButtonBox::StandardButton::Close - - - - - - - - - buttonBox - accepted() - errorDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - errorDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/src/onelauncher/ui/error_message_window.ui b/src/onelauncher/ui/error_message_window.ui new file mode 100644 index 00000000..7eceba9d --- /dev/null +++ b/src/onelauncher/ui/error_message_window.ui @@ -0,0 +1,90 @@ + + + errorMessageWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 400 + 300 + + + + Error + + + true + + + + + + Error: + + + + + + + false + + + QPlainTextEdit::LineWrapMode::NoWrap + + + true + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Close + + + + + + + + + buttonBox + accepted() + errorMessageWindow + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + errorMessageWindow + reject() + + + 316 + 260 + + + 286 + 274 + + + + + \ No newline at end of file diff --git a/src/onelauncher/ui/error_message_uic.py b/src/onelauncher/ui/error_message_window_uic.py similarity index 61% rename from src/onelauncher/ui/error_message_uic.py rename to src/onelauncher/ui/error_message_window_uic.py index 1d06006d..fa727408 100644 --- a/src/onelauncher/ui/error_message_uic.py +++ b/src/onelauncher/ui/error_message_window_uic.py @@ -19,21 +19,21 @@ QLabel, QPlainTextEdit, QSizePolicy, QVBoxLayout, QWidget) -class Ui_errorDialog(object): - def setupUi(self, errorDialog: QDialog) -> None: - if not errorDialog.objectName(): - errorDialog.setObjectName(u"errorDialog") - errorDialog.setWindowModality(Qt.WindowModality.ApplicationModal) - errorDialog.resize(400, 300) - errorDialog.setModal(True) - self.verticalLayout = QVBoxLayout(errorDialog) +class Ui_errorMessageWindow(object): + def setupUi(self, errorMessageWindow: QDialog) -> None: + if not errorMessageWindow.objectName(): + errorMessageWindow.setObjectName(u"errorMessageWindow") + errorMessageWindow.setWindowModality(Qt.WindowModality.ApplicationModal) + errorMessageWindow.resize(400, 300) + errorMessageWindow.setModal(True) + self.verticalLayout = QVBoxLayout(errorMessageWindow) self.verticalLayout.setObjectName(u"verticalLayout") - self.textLabel = QLabel(errorDialog) + self.textLabel = QLabel(errorMessageWindow) self.textLabel.setObjectName(u"textLabel") self.verticalLayout.addWidget(self.textLabel) - self.detailsTextEdit = QPlainTextEdit(errorDialog) + self.detailsTextEdit = QPlainTextEdit(errorMessageWindow) self.detailsTextEdit.setObjectName(u"detailsTextEdit") self.detailsTextEdit.setUndoRedoEnabled(False) self.detailsTextEdit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) @@ -41,7 +41,7 @@ def setupUi(self, errorDialog: QDialog) -> None: self.verticalLayout.addWidget(self.detailsTextEdit) - self.buttonBox = QDialogButtonBox(errorDialog) + self.buttonBox = QDialogButtonBox(errorMessageWindow) self.buttonBox.setObjectName(u"buttonBox") self.buttonBox.setOrientation(Qt.Orientation.Horizontal) self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close) @@ -49,15 +49,15 @@ def setupUi(self, errorDialog: QDialog) -> None: self.verticalLayout.addWidget(self.buttonBox) - self.retranslateUi(errorDialog) - self.buttonBox.accepted.connect(errorDialog.accept) - self.buttonBox.rejected.connect(errorDialog.reject) + self.retranslateUi(errorMessageWindow) + self.buttonBox.accepted.connect(errorMessageWindow.accept) + self.buttonBox.rejected.connect(errorMessageWindow.reject) - QMetaObject.connectSlotsByName(errorDialog) + QMetaObject.connectSlotsByName(errorMessageWindow) # setupUi - def retranslateUi(self, errorDialog: QDialog) -> None: - errorDialog.setWindowTitle(QCoreApplication.translate("errorDialog", u"Error", None)) - self.textLabel.setText(QCoreApplication.translate("errorDialog", u"Error:", None)) + def retranslateUi(self, errorMessageWindow: QDialog) -> None: + errorMessageWindow.setWindowTitle(QCoreApplication.translate("errorMessageWindow", u"Error", None)) + self.textLabel.setText(QCoreApplication.translate("errorMessageWindow", u"Error:", None)) # retranslateUi diff --git a/src/onelauncher/ui/install_game.ui b/src/onelauncher/ui/install_game.ui deleted file mode 100644 index 2e2775d0..00000000 --- a/src/onelauncher/ui/install_game.ui +++ /dev/null @@ -1,151 +0,0 @@ - - - installGameDialog - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 468 - 273 - - - - Install Game - - - true - - - - 9 - - - - - - QFormLayout::RowWrapPolicy::WrapAllRows - - - - - - - Directory where the game will be installed - - - - - - - Select game install directory from the file browser - - - Qt::ArrowType::NoArrow - - - - icon-base - - - - - - - - - - Directory where the game will be installed - - - Install Directory - - - - - - - Which game to install - - - QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents - - - QListView::ResizeMode::Adjust - - - - - - - Which game to install - - - Game Type - - - - - - - - - - 24 - - - - - - - Qt::Orientation::Horizontal - - - QDialogButtonBox::StandardButton::Cancel - - - - - - - - QDialogWithStylePreview - QDialog -
.qtdesigner.custom_widgets
- 1 -
- - FramelessQDialogWithStylePreview - QDialogWithStylePreview -
.custom_widgets
- 1 -
- - NoOddSizesQToolButton - QToolButton -
.custom_widgets
-
-
- - - - buttonBox - rejected() - installGameDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - -
diff --git a/src/onelauncher/ui/install_game.py b/src/onelauncher/ui/install_game_window.py similarity index 98% rename from src/onelauncher/ui/install_game.py rename to src/onelauncher/ui/install_game_window.py index 934f2e8d..ceb027fc 100644 --- a/src/onelauncher/ui/install_game.py +++ b/src/onelauncher/ui/install_game_window.py @@ -24,7 +24,7 @@ from onelauncher.utilities import Progress from .custom_widgets import FramelessQDialogWithStylePreview -from .install_game_uic import Ui_installGameDialog +from .install_game_window_uic import Ui_installGameWindow from .qtapp import get_qapp from .utilities import show_warning_message @@ -42,7 +42,7 @@ def __init__(self, config_manager: ConfigManager) -> None: def setup_ui(self) -> None: self.titleBar.hide() - self.ui = Ui_installGameDialog() + self.ui = Ui_installGameWindow() self.ui.setupUi(self) color_scheme_changed = get_qapp().styleHints().colorSchemeChanged diff --git a/src/onelauncher/ui/install_game_window.ui b/src/onelauncher/ui/install_game_window.ui new file mode 100644 index 00000000..616c8228 --- /dev/null +++ b/src/onelauncher/ui/install_game_window.ui @@ -0,0 +1,153 @@ + + + installGameWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 468 + 273 + + + + Install Game + + + true + + + + 9 + + + + + + QFormLayout::RowWrapPolicy::WrapAllRows + + + + + + + Directory where the game will be installed + + + + + + + Select game install directory from the file + browser + + + Qt::ArrowType::NoArrow + + + + icon-base + + + + + + + + + + Directory where the game will be installed + + + Install Directory + + + + + + + Which game to install + + + QAbstractScrollArea::SizeAdjustPolicy::AdjustToContents + + + QListView::ResizeMode::Adjust + + + + + + + Which game to install + + + Game Type + + + + + + + + + + 24 + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel + + + + + + + + QDialogWithStylePreview + QDialog +
.qtdesigner.custom_widgets
+ 1 +
+ + FramelessQDialogWithStylePreview + QDialogWithStylePreview +
.custom_widgets
+ 1 +
+ + NoOddSizesQToolButton + QToolButton +
.custom_widgets
+
+
+ + + + buttonBox + rejected() + installGameWindow + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
\ No newline at end of file diff --git a/src/onelauncher/ui/install_game_uic.py b/src/onelauncher/ui/install_game_window_uic.py similarity index 76% rename from src/onelauncher/ui/install_game_uic.py rename to src/onelauncher/ui/install_game_window_uic.py index a3b641ad..f01fc224 100644 --- a/src/onelauncher/ui/install_game_uic.py +++ b/src/onelauncher/ui/install_game_window_uic.py @@ -23,17 +23,17 @@ from .custom_widgets import (FramelessQDialogWithStylePreview, NoOddSizesQToolButton) from .qtdesigner.custom_widgets import QDialogWithStylePreview -class Ui_installGameDialog(object): - def setupUi(self, installGameDialog: FramelessQDialogWithStylePreview) -> None: - if not installGameDialog.objectName(): - installGameDialog.setObjectName(u"installGameDialog") - installGameDialog.setWindowModality(Qt.WindowModality.ApplicationModal) - installGameDialog.resize(468, 273) - installGameDialog.setModal(True) - self.verticalLayout = QVBoxLayout(installGameDialog) +class Ui_installGameWindow(object): + def setupUi(self, installGameWindow: FramelessQDialogWithStylePreview) -> None: + if not installGameWindow.objectName(): + installGameWindow.setObjectName(u"installGameWindow") + installGameWindow.setWindowModality(Qt.WindowModality.ApplicationModal) + installGameWindow.resize(468, 273) + installGameWindow.setModal(True) + self.verticalLayout = QVBoxLayout(installGameWindow) self.verticalLayout.setSpacing(9) self.verticalLayout.setObjectName(u"verticalLayout") - self.widgetInstallOptions = QWidget(installGameDialog) + self.widgetInstallOptions = QWidget(installGameWindow) self.widgetInstallOptions.setObjectName(u"widgetInstallOptions") self.formLayout = QFormLayout(self.widgetInstallOptions) self.formLayout.setObjectName(u"formLayout") @@ -74,13 +74,13 @@ def setupUi(self, installGameDialog: FramelessQDialogWithStylePreview) -> None: self.verticalLayout.addWidget(self.widgetInstallOptions) - self.progressBar = QProgressBar(installGameDialog) + self.progressBar = QProgressBar(installGameWindow) self.progressBar.setObjectName(u"progressBar") self.progressBar.setValue(24) self.verticalLayout.addWidget(self.progressBar) - self.buttonBox = QDialogButtonBox(installGameDialog) + self.buttonBox = QDialogButtonBox(installGameWindow) self.buttonBox.setObjectName(u"buttonBox") self.buttonBox.setOrientation(Qt.Orientation.Horizontal) self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel) @@ -88,32 +88,32 @@ def setupUi(self, installGameDialog: FramelessQDialogWithStylePreview) -> None: self.verticalLayout.addWidget(self.buttonBox) - self.retranslateUi(installGameDialog) - self.buttonBox.rejected.connect(installGameDialog.reject) + self.retranslateUi(installGameWindow) + self.buttonBox.rejected.connect(installGameWindow.reject) - QMetaObject.connectSlotsByName(installGameDialog) + QMetaObject.connectSlotsByName(installGameWindow) # setupUi - def retranslateUi(self, installGameDialog: FramelessQDialogWithStylePreview) -> None: - installGameDialog.setWindowTitle(QCoreApplication.translate("installGameDialog", u"Install Game", None)) + def retranslateUi(self, installGameWindow: FramelessQDialogWithStylePreview) -> None: + installGameWindow.setWindowTitle(QCoreApplication.translate("installGameWindow", u"Install Game", None)) #if QT_CONFIG(tooltip) - self.installDirLineEdit.setToolTip(QCoreApplication.translate("installGameDialog", u"Directory where the game will be installed", None)) + self.installDirLineEdit.setToolTip(QCoreApplication.translate("installGameWindow", u"Directory where the game will be installed", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.selectInstallDirButton.setToolTip(QCoreApplication.translate("installGameDialog", u"Select game install directory from the file browser", None)) + self.selectInstallDirButton.setToolTip(QCoreApplication.translate("installGameWindow", u"Select game install directory from the file browser", None)) #endif // QT_CONFIG(tooltip) self.selectInstallDirButton.setProperty(u"qssClass", [ - QCoreApplication.translate("installGameDialog", u"icon-base", None)]) + QCoreApplication.translate("installGameWindow", u"icon-base", None)]) #if QT_CONFIG(tooltip) - self.installDirLabel.setToolTip(QCoreApplication.translate("installGameDialog", u"Directory where the game will be installed", None)) + self.installDirLabel.setToolTip(QCoreApplication.translate("installGameWindow", u"Directory where the game will be installed", None)) #endif // QT_CONFIG(tooltip) - self.installDirLabel.setText(QCoreApplication.translate("installGameDialog", u"Install Directory", None)) + self.installDirLabel.setText(QCoreApplication.translate("installGameWindow", u"Install Directory", None)) #if QT_CONFIG(tooltip) - self.gameTypeListWidget.setToolTip(QCoreApplication.translate("installGameDialog", u"Which game to install", None)) + self.gameTypeListWidget.setToolTip(QCoreApplication.translate("installGameWindow", u"Which game to install", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameTypeLabel.setToolTip(QCoreApplication.translate("installGameDialog", u"Which game to install", None)) + self.gameTypeLabel.setToolTip(QCoreApplication.translate("installGameWindow", u"Which game to install", None)) #endif // QT_CONFIG(tooltip) - self.gameTypeLabel.setText(QCoreApplication.translate("installGameDialog", u"Game Type", None)) + self.gameTypeLabel.setText(QCoreApplication.translate("installGameWindow", u"Game Type", None)) # retranslateUi diff --git a/src/onelauncher/ui/log_window.ui b/src/onelauncher/ui/log_window.ui deleted file mode 100644 index 8267464e..00000000 --- a/src/onelauncher/ui/log_window.ui +++ /dev/null @@ -1,80 +0,0 @@ - - - logDialog - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 400 - 300 - - - - Logs - - - true - - - - - - false - - - true - - - - - - - Qt::Orientation::Horizontal - - - QDialogButtonBox::StandardButton::Close - - - - - - - - - buttonBox - accepted() - logDialog - accept() - - - 248 - 254 - - - 157 - 274 - - - - - buttonBox - rejected() - logDialog - reject() - - - 316 - 260 - - - 286 - 274 - - - - - diff --git a/src/onelauncher/ui/log_window_uic.py b/src/onelauncher/ui/log_window_uic.py deleted file mode 100644 index 84489326..00000000 --- a/src/onelauncher/ui/log_window_uic.py +++ /dev/null @@ -1,55 +0,0 @@ -# -*- coding: utf-8 -*- - -################################################################################ -## Form generated from reading UI file 'log_window.ui' -## -## Created by: Qt User Interface Compiler version 6.7.2 -## -## WARNING! All changes made in this file will be lost when recompiling UI file! -################################################################################ - -from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, - QMetaObject, QObject, QPoint, QRect, - QSize, QTime, QUrl, Qt) -from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, - QFont, QFontDatabase, QGradient, QIcon, - QImage, QKeySequence, QLinearGradient, QPainter, - QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, - QPlainTextEdit, QSizePolicy, QVBoxLayout, QWidget) - -class Ui_logDialog(object): - def setupUi(self, logDialog: QDialog) -> None: - if not logDialog.objectName(): - logDialog.setObjectName(u"logDialog") - logDialog.setWindowModality(Qt.WindowModality.ApplicationModal) - logDialog.resize(400, 300) - logDialog.setModal(True) - self.verticalLayout = QVBoxLayout(logDialog) - self.verticalLayout.setObjectName(u"verticalLayout") - self.detailsTextEdit = QPlainTextEdit(logDialog) - self.detailsTextEdit.setObjectName(u"detailsTextEdit") - self.detailsTextEdit.setUndoRedoEnabled(False) - self.detailsTextEdit.setReadOnly(True) - - self.verticalLayout.addWidget(self.detailsTextEdit) - - self.buttonBox = QDialogButtonBox(logDialog) - self.buttonBox.setObjectName(u"buttonBox") - self.buttonBox.setOrientation(Qt.Orientation.Horizontal) - self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Close) - - self.verticalLayout.addWidget(self.buttonBox) - - - self.retranslateUi(logDialog) - self.buttonBox.accepted.connect(logDialog.accept) - self.buttonBox.rejected.connect(logDialog.reject) - - QMetaObject.connectSlotsByName(logDialog) - # setupUi - - def retranslateUi(self, logDialog: QDialog) -> None: - logDialog.setWindowTitle(QCoreApplication.translate("logDialog", u"Logs", None)) - # retranslateUi - diff --git a/src/onelauncher/ui/main.ui b/src/onelauncher/ui/main.ui deleted file mode 100644 index 0b8c2af8..00000000 --- a/src/onelauncher/ui/main.ui +++ /dev/null @@ -1,557 +0,0 @@ - - - winMain - - - - 0 - 0 - 790 - 470 - - - - - - 3 - - - 6 - - - 3 - - - 6 - - - 6 - - - - - 2 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::FocusPolicy::ClickFocus - - - Settings - - - true - - - - icon-lg - - - - - - - - Qt::FocusPolicy::ClickFocus - - - Addon manager - - - true - - - - icon-lg - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - Qt::FocusPolicy::ClickFocus - - - About - - - true - - - - icon-lg - - - - - - - - Qt::FocusPolicy::ClickFocus - - - Minimize - - - true - - - - icon-lg - - - - - - - - Qt::FocusPolicy::ClickFocus - - - Exit - - - true - - - - icon-lg - - - - - - - - - - 6 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 9 - - - - - false - - - Qt::AlignmentFlag::AlignCenter - - - - - - - - 1 - 2 - - - - true - - - true - - - - - - - - - 0 - - - - - - 6 - - - 6 - - - 6 - - - - - Game server - - - World - - - cboWorld - - - - - - - 6 - - - - - Select game server - - - - - - - Qt::FocusPolicy::ClickFocus - - - Switch game - - - QToolButton::ToolButtonPopupMode::MenuButtonPopup - - - Qt::ToolButtonStyle::ToolButtonIconOnly - - - - icon-xl - - - - - - - - - - Account - - - cboAccount - - - - - - - true - - - QComboBox::InsertPolicy::NoInsert - - - - - - - Password - - - txtPassword - - - - - - - QLineEdit::EchoMode::Password - - - true - - - - - - - Start your adventure! - - - Play - - - QToolButton::ToolButtonPopupMode::MenuButtonPopup - - - - text-xl - px-3.5 - py-2 - m-2 - - - - - - - - - 0 - - - QLayout::SizeConstraint::SetFixedSize - - - 6 - - - 0 - - - 0 - - - 0 - - - - - Save last used world and account name - - - Remember account - - - - - - - Save last used password - - - Remember password - - - - - - - Qt::Orientation::Vertical - - - QSizePolicy::Policy::Expanding - - - - 0 - 0 - - - - - - - - - - - - - - - 0 - 1 - - - - Qt::TextInteractionFlag::TextSelectableByMouse - - - false - - - - - - - - - - - - Patch - - - Patch - - - Patch - - - - - Lord of the Rings Online - - - - - Dungeons and Dragons Online - - - - - About - - - QAction::MenuRole::AboutRole - - - - - Settings - - - Settings - - - QAction::MenuRole::PreferencesRole - - - - - Exit - - - Exit - - - QAction::MenuRole::QuitRole - - - - - - - QMainWindowWithStylePreview - QMainWindow -
.qtdesigner.custom_widgets
- 1 -
- - FramelessQMainWindowWithStylePreview - QMainWindowWithStylePreview -
.custom_widgets
- 1 -
- - GameNewsfeedBrowser - QTextBrowser -
.custom_widgets
-
- - NoOddSizesQToolButton - QToolButton -
.custom_widgets
-
-
- - cboWorld - cboAccount - txtPassword - btnStartGame - chkSaveAccount - chkSavePassword - txtFeed - txtStatus - - - - - actionAbout - triggered() - btnAbout - click() - - - -1 - -1 - - - 698 - 19 - - - - - actionSettings - triggered() - btnOptions - click() - - - -1 - -1 - - - 22 - 19 - - - - - actionExit - triggered() - btnExit - click() - - - -1 - -1 - - - 766 - 19 - - - - -
diff --git a/src/onelauncher/ui/main_window.ui b/src/onelauncher/ui/main_window.ui new file mode 100644 index 00000000..96b9ccd5 --- /dev/null +++ b/src/onelauncher/ui/main_window.ui @@ -0,0 +1,570 @@ + + + mainWindow + + + + 0 + 0 + 790 + 470 + + + + + + 3 + + + 6 + + + 3 + + + 6 + + + 6 + + + + + 2 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::FocusPolicy::ClickFocus + + + Settings + + + true + + + + icon-lg + + + + + + + + Qt::FocusPolicy::ClickFocus + + + Addon manager + + + true + + + + icon-lg + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + Qt::FocusPolicy::ClickFocus + + + About + + + true + + + + icon-lg + + + + + + + + Qt::FocusPolicy::ClickFocus + + + Minimize + + + true + + + + icon-lg + + + + + + + + Qt::FocusPolicy::ClickFocus + + + Exit + + + true + + + + icon-lg + + + + + + + + + + 6 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 9 + + + + + false + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 1 + 2 + + + + true + + + true + + + + + + + + + 0 + + + + + + 6 + + + 6 + + + 6 + + + + + Game server + + + World + + + cboWorld + + + + + + + 6 + + + + + Select game server + + + + + + + Qt::FocusPolicy::ClickFocus + + + Switch game + + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + + Qt::ToolButtonStyle::ToolButtonIconOnly + + + + icon-xl + + + + + + + + + + Account + + + cboAccount + + + + + + + true + + + QComboBox::InsertPolicy::NoInsert + + + + + + + Password + + + txtPassword + + + + + + + QLineEdit::EchoMode::Password + + + true + + + + + + + Start your adventure! + + + Play + + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + + text-xl + px-3.5 + py-2 + m-2 + + + + + + + + + 0 + + + + QLayout::SizeConstraint::SetFixedSize + + + 6 + + + 0 + + + 0 + + + 0 + + + + + Save last used world and + account name + + + Remember account + + + + + + + Save last used password + + + Remember password + + + + + + + Qt::Orientation::Vertical + + + + QSizePolicy::Policy::Expanding + + + + 0 + 0 + + + + + + + + + + + + + + + 0 + 1 + + + + Qt::TextInteractionFlag::TextSelectableByMouse + + + false + + + + + + + + + + + + Patch + + + Patch + + + Patch + + + + + Lord of the Rings Online + + + + + Dungeons and Dragons Online + + + + + About + + + QAction::MenuRole::AboutRole + + + + + Settings + + + Settings + + + QAction::MenuRole::PreferencesRole + + + + + Exit + + + Exit + + + QAction::MenuRole::QuitRole + + + + + + + QMainWindowWithStylePreview + QMainWindow +
.qtdesigner.custom_widgets
+ 1 +
+ + FramelessQMainWindowWithStylePreview + QMainWindowWithStylePreview +
.custom_widgets
+ 1 +
+ + GameNewsfeedBrowser + QTextBrowser +
.custom_widgets
+
+ + NoOddSizesQToolButton + QToolButton +
.custom_widgets
+
+
+ + cboWorld + cboAccount + txtPassword + btnStartGame + chkSaveAccount + chkSavePassword + txtFeed + txtStatus + + + + + actionAbout + triggered() + btnAbout + click() + + + -1 + -1 + + + 698 + 19 + + + + + actionSettings + triggered() + btnOptions + click() + + + -1 + -1 + + + 22 + 19 + + + + + actionExit + triggered() + btnExit + click() + + + -1 + -1 + + + 766 + 19 + + + + +
\ No newline at end of file diff --git a/src/onelauncher/ui/main_uic.py b/src/onelauncher/ui/main_window_uic.py similarity index 79% rename from src/onelauncher/ui/main_uic.py rename to src/onelauncher/ui/main_window_uic.py index 07f4e5e9..715887bd 100644 --- a/src/onelauncher/ui/main_uic.py +++ b/src/onelauncher/ui/main_window_uic.py @@ -24,27 +24,27 @@ from .custom_widgets import (FramelessQMainWindowWithStylePreview, GameNewsfeedBrowser, NoOddSizesQToolButton) from .qtdesigner.custom_widgets import QMainWindowWithStylePreview -class Ui_winMain(object): - def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: - if not winMain.objectName(): - winMain.setObjectName(u"winMain") - winMain.resize(790, 470) - self.actionPatch = QAction(winMain) +class Ui_mainWindow(object): + def setupUi(self, mainWindow: FramelessQMainWindowWithStylePreview) -> None: + if not mainWindow.objectName(): + mainWindow.setObjectName(u"mainWindow") + mainWindow.resize(790, 470) + self.actionPatch = QAction(mainWindow) self.actionPatch.setObjectName(u"actionPatch") - self.actionLOTRO = QAction(winMain) + self.actionLOTRO = QAction(mainWindow) self.actionLOTRO.setObjectName(u"actionLOTRO") - self.actionDDO = QAction(winMain) + self.actionDDO = QAction(mainWindow) self.actionDDO.setObjectName(u"actionDDO") - self.actionAbout = QAction(winMain) + self.actionAbout = QAction(mainWindow) self.actionAbout.setObjectName(u"actionAbout") self.actionAbout.setMenuRole(QAction.MenuRole.AboutRole) - self.actionSettings = QAction(winMain) + self.actionSettings = QAction(mainWindow) self.actionSettings.setObjectName(u"actionSettings") self.actionSettings.setMenuRole(QAction.MenuRole.PreferencesRole) - self.actionExit = QAction(winMain) + self.actionExit = QAction(mainWindow) self.actionExit.setObjectName(u"actionExit") self.actionExit.setMenuRole(QAction.MenuRole.QuitRole) - self.centralwidget = QWidget(winMain) + self.centralwidget = QWidget(mainWindow) self.centralwidget.setObjectName(u"centralwidget") self.verticalLayout_4 = QVBoxLayout(self.centralwidget) self.verticalLayout_4.setSpacing(3) @@ -243,7 +243,7 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: self.verticalLayout_4.addLayout(self.horizontalLayout_4) - winMain.setCentralWidget(self.centralwidget) + mainWindow.setCentralWidget(self.centralwidget) #if QT_CONFIG(shortcut) self.lblWorld.setBuddy(self.cboWorld) self.lblAccount.setBuddy(self.cboAccount) @@ -257,87 +257,87 @@ def setupUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: QWidget.setTabOrder(self.chkSavePassword, self.txtFeed) QWidget.setTabOrder(self.txtFeed, self.txtStatus) - self.retranslateUi(winMain) + self.retranslateUi(mainWindow) self.actionAbout.triggered.connect(self.btnAbout.click) self.actionSettings.triggered.connect(self.btnOptions.click) self.actionExit.triggered.connect(self.btnExit.click) - QMetaObject.connectSlotsByName(winMain) + QMetaObject.connectSlotsByName(mainWindow) # setupUi - def retranslateUi(self, winMain: FramelessQMainWindowWithStylePreview) -> None: - self.actionPatch.setText(QCoreApplication.translate("winMain", u"Patch", None)) - self.actionPatch.setIconText(QCoreApplication.translate("winMain", u"Patch", None)) + def retranslateUi(self, mainWindow: FramelessQMainWindowWithStylePreview) -> None: + self.actionPatch.setText(QCoreApplication.translate("mainWindow", u"Patch", None)) + self.actionPatch.setIconText(QCoreApplication.translate("mainWindow", u"Patch", None)) #if QT_CONFIG(tooltip) - self.actionPatch.setToolTip(QCoreApplication.translate("winMain", u"Patch", None)) + self.actionPatch.setToolTip(QCoreApplication.translate("mainWindow", u"Patch", None)) #endif // QT_CONFIG(tooltip) - self.actionLOTRO.setText(QCoreApplication.translate("winMain", u"Lord of the Rings Online", None)) - self.actionDDO.setText(QCoreApplication.translate("winMain", u"Dungeons and Dragons Online", None)) - self.actionAbout.setText(QCoreApplication.translate("winMain", u"About", None)) - self.actionSettings.setText(QCoreApplication.translate("winMain", u"Settings", None)) + self.actionLOTRO.setText(QCoreApplication.translate("mainWindow", u"Lord of the Rings Online", None)) + self.actionDDO.setText(QCoreApplication.translate("mainWindow", u"Dungeons and Dragons Online", None)) + self.actionAbout.setText(QCoreApplication.translate("mainWindow", u"About", None)) + self.actionSettings.setText(QCoreApplication.translate("mainWindow", u"Settings", None)) #if QT_CONFIG(tooltip) - self.actionSettings.setToolTip(QCoreApplication.translate("winMain", u"Settings", None)) + self.actionSettings.setToolTip(QCoreApplication.translate("mainWindow", u"Settings", None)) #endif // QT_CONFIG(tooltip) - self.actionExit.setText(QCoreApplication.translate("winMain", u"Exit", None)) + self.actionExit.setText(QCoreApplication.translate("mainWindow", u"Exit", None)) #if QT_CONFIG(tooltip) - self.actionExit.setToolTip(QCoreApplication.translate("winMain", u"Exit", None)) + self.actionExit.setToolTip(QCoreApplication.translate("mainWindow", u"Exit", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.btnOptions.setToolTip(QCoreApplication.translate("winMain", u"Settings", None)) + self.btnOptions.setToolTip(QCoreApplication.translate("mainWindow", u"Settings", None)) #endif // QT_CONFIG(tooltip) self.btnOptions.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnAddonManager.setToolTip(QCoreApplication.translate("winMain", u"Addon manager", None)) + self.btnAddonManager.setToolTip(QCoreApplication.translate("mainWindow", u"Addon manager", None)) #endif // QT_CONFIG(tooltip) self.btnAddonManager.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnAbout.setToolTip(QCoreApplication.translate("winMain", u"About", None)) + self.btnAbout.setToolTip(QCoreApplication.translate("mainWindow", u"About", None)) #endif // QT_CONFIG(tooltip) self.btnAbout.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnMinimize.setToolTip(QCoreApplication.translate("winMain", u"Minimize", None)) + self.btnMinimize.setToolTip(QCoreApplication.translate("mainWindow", u"Minimize", None)) #endif // QT_CONFIG(tooltip) self.btnMinimize.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnExit.setToolTip(QCoreApplication.translate("winMain", u"Exit", None)) + self.btnExit.setToolTip(QCoreApplication.translate("mainWindow", u"Exit", None)) #endif // QT_CONFIG(tooltip) self.btnExit.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.lblWorld.setToolTip(QCoreApplication.translate("winMain", u"Game server", None)) + self.lblWorld.setToolTip(QCoreApplication.translate("mainWindow", u"Game server", None)) #endif // QT_CONFIG(tooltip) - self.lblWorld.setText(QCoreApplication.translate("winMain", u"World", None)) + self.lblWorld.setText(QCoreApplication.translate("mainWindow", u"World", None)) #if QT_CONFIG(tooltip) - self.cboWorld.setToolTip(QCoreApplication.translate("winMain", u"Select game server", None)) + self.cboWorld.setToolTip(QCoreApplication.translate("mainWindow", u"Select game server", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.btnSwitchGame.setToolTip(QCoreApplication.translate("winMain", u"Switch game", None)) + self.btnSwitchGame.setToolTip(QCoreApplication.translate("mainWindow", u"Switch game", None)) #endif // QT_CONFIG(tooltip) self.btnSwitchGame.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"icon-xl", None)]) - self.lblAccount.setText(QCoreApplication.translate("winMain", u"Account", None)) - self.lblPassword.setText(QCoreApplication.translate("winMain", u"Password", None)) + QCoreApplication.translate("mainWindow", u"icon-xl", None)]) + self.lblAccount.setText(QCoreApplication.translate("mainWindow", u"Account", None)) + self.lblPassword.setText(QCoreApplication.translate("mainWindow", u"Password", None)) #if QT_CONFIG(tooltip) - self.btnStartGame.setToolTip(QCoreApplication.translate("winMain", u"Start your adventure!", None)) + self.btnStartGame.setToolTip(QCoreApplication.translate("mainWindow", u"Start your adventure!", None)) #endif // QT_CONFIG(tooltip) - self.btnStartGame.setText(QCoreApplication.translate("winMain", u"Play", None)) + self.btnStartGame.setText(QCoreApplication.translate("mainWindow", u"Play", None)) self.btnStartGame.setProperty(u"qssClass", [ - QCoreApplication.translate("winMain", u"text-xl", None), - QCoreApplication.translate("winMain", u"px-3.5", None), - QCoreApplication.translate("winMain", u"py-2", None), - QCoreApplication.translate("winMain", u"m-2", None)]) + QCoreApplication.translate("mainWindow", u"text-xl", None), + QCoreApplication.translate("mainWindow", u"px-3.5", None), + QCoreApplication.translate("mainWindow", u"py-2", None), + QCoreApplication.translate("mainWindow", u"m-2", None)]) #if QT_CONFIG(tooltip) - self.chkSaveAccount.setToolTip(QCoreApplication.translate("winMain", u"Save last used world and account name", None)) + self.chkSaveAccount.setToolTip(QCoreApplication.translate("mainWindow", u"Save last used world and account name", None)) #endif // QT_CONFIG(tooltip) - self.chkSaveAccount.setText(QCoreApplication.translate("winMain", u"Remember account", None)) + self.chkSaveAccount.setText(QCoreApplication.translate("mainWindow", u"Remember account", None)) #if QT_CONFIG(tooltip) - self.chkSavePassword.setToolTip(QCoreApplication.translate("winMain", u"Save last used password", None)) + self.chkSavePassword.setToolTip(QCoreApplication.translate("mainWindow", u"Save last used password", None)) #endif // QT_CONFIG(tooltip) - self.chkSavePassword.setText(QCoreApplication.translate("winMain", u"Remember password", None)) + self.chkSavePassword.setText(QCoreApplication.translate("mainWindow", u"Remember password", None)) pass # retranslateUi diff --git a/src/onelauncher/ui/patch_game.ui b/src/onelauncher/ui/patch_game.ui deleted file mode 100644 index 056f8aea..00000000 --- a/src/onelauncher/ui/patch_game.ui +++ /dev/null @@ -1,63 +0,0 @@ - - - patchingDialog - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 720 - 400 - - - - MainWindow - - - true - - - - - - - - - - - 24 - - - %p% (%v/%m) - - - - - - - Start - - - - - - - Stop - - - - - - - - - btnStart - btnStop - txtLog - - - - diff --git a/src/onelauncher/ui/patch_game.py b/src/onelauncher/ui/patch_game_window.py similarity index 98% rename from src/onelauncher/ui/patch_game.py rename to src/onelauncher/ui/patch_game_window.py index d53233fd..d7a6fa9d 100644 --- a/src/onelauncher/ui/patch_game.py +++ b/src/onelauncher/ui/patch_game_window.py @@ -43,7 +43,7 @@ from onelauncher.patch_game import logger as patch_game_logger from onelauncher.utilities import Progress -from .patch_game_uic import Ui_patchingDialog +from .patch_game_window_uic import Ui_patchGameWindow from .qtapp import get_qapp from .utilities import log_record_to_rich_text @@ -67,7 +67,7 @@ def __init__( self.progress: Progress | None = None - self.ui = Ui_patchingDialog() + self.ui = Ui_patchGameWindow() self.ui.setupUi(self) self.setWindowTitle("Patching Output") diff --git a/src/onelauncher/ui/patch_game_window.ui b/src/onelauncher/ui/patch_game_window.ui new file mode 100644 index 00000000..10c9b749 --- /dev/null +++ b/src/onelauncher/ui/patch_game_window.ui @@ -0,0 +1,63 @@ + + + patchGameWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 720 + 400 + + + + MainWindow + + + true + + + + + + + + + + + 24 + + + %p% (%v/%m) + + + + + + + Start + + + + + + + Stop + + + + + + + + + btnStart + btnStop + txtLog + + + + \ No newline at end of file diff --git a/src/onelauncher/ui/patch_game_uic.py b/src/onelauncher/ui/patch_game_window_uic.py similarity index 61% rename from src/onelauncher/ui/patch_game_uic.py rename to src/onelauncher/ui/patch_game_window_uic.py index d5bb5969..1ffb091a 100644 --- a/src/onelauncher/ui/patch_game_uic.py +++ b/src/onelauncher/ui/patch_game_window_uic.py @@ -19,34 +19,34 @@ QPushButton, QSizePolicy, QTextBrowser, QVBoxLayout, QWidget) -class Ui_patchingDialog(object): - def setupUi(self, patchingDialog: QDialog) -> None: - if not patchingDialog.objectName(): - patchingDialog.setObjectName(u"patchingDialog") - patchingDialog.setWindowModality(Qt.WindowModality.ApplicationModal) - patchingDialog.resize(720, 400) - patchingDialog.setModal(True) - self.verticalLayout = QVBoxLayout(patchingDialog) +class Ui_patchGameWindow(object): + def setupUi(self, patchGameWindow: QDialog) -> None: + if not patchGameWindow.objectName(): + patchGameWindow.setObjectName(u"patchGameWindow") + patchGameWindow.setWindowModality(Qt.WindowModality.ApplicationModal) + patchGameWindow.resize(720, 400) + patchGameWindow.setModal(True) + self.verticalLayout = QVBoxLayout(patchGameWindow) self.verticalLayout.setObjectName(u"verticalLayout") - self.txtLog = QTextBrowser(patchingDialog) + self.txtLog = QTextBrowser(patchGameWindow) self.txtLog.setObjectName(u"txtLog") self.verticalLayout.addWidget(self.txtLog) self.horizontalLayout = QHBoxLayout() self.horizontalLayout.setObjectName(u"horizontalLayout") - self.progressBar = QProgressBar(patchingDialog) + self.progressBar = QProgressBar(patchGameWindow) self.progressBar.setObjectName(u"progressBar") self.progressBar.setValue(24) self.horizontalLayout.addWidget(self.progressBar) - self.btnStart = QPushButton(patchingDialog) + self.btnStart = QPushButton(patchGameWindow) self.btnStart.setObjectName(u"btnStart") self.horizontalLayout.addWidget(self.btnStart) - self.btnStop = QPushButton(patchingDialog) + self.btnStop = QPushButton(patchGameWindow) self.btnStop.setObjectName(u"btnStop") self.horizontalLayout.addWidget(self.btnStop) @@ -57,15 +57,15 @@ def setupUi(self, patchingDialog: QDialog) -> None: QWidget.setTabOrder(self.btnStart, self.btnStop) QWidget.setTabOrder(self.btnStop, self.txtLog) - self.retranslateUi(patchingDialog) + self.retranslateUi(patchGameWindow) - QMetaObject.connectSlotsByName(patchingDialog) + QMetaObject.connectSlotsByName(patchGameWindow) # setupUi - def retranslateUi(self, patchingDialog: QDialog) -> None: - patchingDialog.setWindowTitle(QCoreApplication.translate("patchingDialog", u"MainWindow", None)) - self.progressBar.setFormat(QCoreApplication.translate("patchingDialog", u"%p% (%v/%m)", None)) - self.btnStart.setText(QCoreApplication.translate("patchingDialog", u"Start", None)) - self.btnStop.setText(QCoreApplication.translate("patchingDialog", u"Stop", None)) + def retranslateUi(self, patchGameWindow: QDialog) -> None: + patchGameWindow.setWindowTitle(QCoreApplication.translate("patchGameWindow", u"MainWindow", None)) + self.progressBar.setFormat(QCoreApplication.translate("patchGameWindow", u"%p% (%v/%m)", None)) + self.btnStart.setText(QCoreApplication.translate("patchGameWindow", u"Start", None)) + self.btnStop.setText(QCoreApplication.translate("patchGameWindow", u"Stop", None)) # retranslateUi diff --git a/src/onelauncher/ui/select_subscription.ui b/src/onelauncher/ui/select_subscription.ui deleted file mode 100644 index 6202e5ba..00000000 --- a/src/onelauncher/ui/select_subscription.ui +++ /dev/null @@ -1,91 +0,0 @@ - - - dlgSelectSubscription - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 320 - 169 - - - - Select Subscription - - - true - - - - 9 - - - - - Multiple game sub-accounts found - -Please select one - - - Qt::AlignmentFlag::AlignCenter - - - subscriptionsComboBox - - - - - - - - - - Qt::Orientation::Horizontal - - - QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok - - - - - - - - - buttonBox - accepted() - dlgSelectSubscription - accept() - - - 227 - 148 - - - 159 - 84 - - - - - buttonBox - rejected() - dlgSelectSubscription - reject() - - - 227 - 148 - - - 159 - 84 - - - - - diff --git a/src/onelauncher/ui/select_subscription_window.ui b/src/onelauncher/ui/select_subscription_window.ui new file mode 100644 index 00000000..d0c7c012 --- /dev/null +++ b/src/onelauncher/ui/select_subscription_window.ui @@ -0,0 +1,92 @@ + + + selectSubscriptionWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 320 + 169 + + + + Select Subscription + + + true + + + + 9 + + + + + Multiple game sub-accounts found + + Please select one + + + Qt::AlignmentFlag::AlignCenter + + + subscriptionsComboBox + + + + + + + + + + Qt::Orientation::Horizontal + + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + selectSubscriptionWindow + accept() + + + 227 + 148 + + + 159 + 84 + + + + + buttonBox + rejected() + selectSubscriptionWindow + reject() + + + 227 + 148 + + + 159 + 84 + + + + + \ No newline at end of file diff --git a/src/onelauncher/ui/select_subscription_uic.py b/src/onelauncher/ui/select_subscription_window_uic.py similarity index 58% rename from src/onelauncher/ui/select_subscription_uic.py rename to src/onelauncher/ui/select_subscription_window_uic.py index 34816566..6d157040 100644 --- a/src/onelauncher/ui/select_subscription_uic.py +++ b/src/onelauncher/ui/select_subscription_window_uic.py @@ -19,28 +19,28 @@ QDialogButtonBox, QLabel, QSizePolicy, QVBoxLayout, QWidget) -class Ui_dlgSelectSubscription(object): - def setupUi(self, dlgSelectSubscription: QDialog) -> None: - if not dlgSelectSubscription.objectName(): - dlgSelectSubscription.setObjectName(u"dlgSelectSubscription") - dlgSelectSubscription.setWindowModality(Qt.WindowModality.ApplicationModal) - dlgSelectSubscription.resize(320, 169) - dlgSelectSubscription.setModal(True) - self.verticalLayout = QVBoxLayout(dlgSelectSubscription) +class Ui_selectSubscriptionWindow(object): + def setupUi(self, selectSubscriptionWindow: QDialog) -> None: + if not selectSubscriptionWindow.objectName(): + selectSubscriptionWindow.setObjectName(u"selectSubscriptionWindow") + selectSubscriptionWindow.setWindowModality(Qt.WindowModality.ApplicationModal) + selectSubscriptionWindow.resize(320, 169) + selectSubscriptionWindow.setModal(True) + self.verticalLayout = QVBoxLayout(selectSubscriptionWindow) self.verticalLayout.setSpacing(9) self.verticalLayout.setObjectName(u"verticalLayout") - self.label = QLabel(dlgSelectSubscription) + self.label = QLabel(selectSubscriptionWindow) self.label.setObjectName(u"label") self.label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.verticalLayout.addWidget(self.label) - self.subscriptionsComboBox = QComboBox(dlgSelectSubscription) + self.subscriptionsComboBox = QComboBox(selectSubscriptionWindow) self.subscriptionsComboBox.setObjectName(u"subscriptionsComboBox") self.verticalLayout.addWidget(self.subscriptionsComboBox) - self.buttonBox = QDialogButtonBox(dlgSelectSubscription) + self.buttonBox = QDialogButtonBox(selectSubscriptionWindow) self.buttonBox.setObjectName(u"buttonBox") self.buttonBox.setOrientation(Qt.Orientation.Horizontal) self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Ok) @@ -51,16 +51,16 @@ def setupUi(self, dlgSelectSubscription: QDialog) -> None: self.label.setBuddy(self.subscriptionsComboBox) #endif // QT_CONFIG(shortcut) - self.retranslateUi(dlgSelectSubscription) - self.buttonBox.accepted.connect(dlgSelectSubscription.accept) - self.buttonBox.rejected.connect(dlgSelectSubscription.reject) + self.retranslateUi(selectSubscriptionWindow) + self.buttonBox.accepted.connect(selectSubscriptionWindow.accept) + self.buttonBox.rejected.connect(selectSubscriptionWindow.reject) - QMetaObject.connectSlotsByName(dlgSelectSubscription) + QMetaObject.connectSlotsByName(selectSubscriptionWindow) # setupUi - def retranslateUi(self, dlgSelectSubscription: QDialog) -> None: - dlgSelectSubscription.setWindowTitle(QCoreApplication.translate("dlgSelectSubscription", u"Select Subscription", None)) - self.label.setText(QCoreApplication.translate("dlgSelectSubscription", u"Multiple game sub-accounts found\n" + def retranslateUi(self, selectSubscriptionWindow: QDialog) -> None: + selectSubscriptionWindow.setWindowTitle(QCoreApplication.translate("selectSubscriptionWindow", u"Select Subscription", None)) + self.label.setText(QCoreApplication.translate("selectSubscriptionWindow", u"Multiple game sub-accounts found\n" "\n" "Please select one", None)) # retranslateUi diff --git a/src/onelauncher/ui/settings.ui b/src/onelauncher/ui/settings.ui deleted file mode 100644 index 2e8c3eac..00000000 --- a/src/onelauncher/ui/settings.ui +++ /dev/null @@ -1,769 +0,0 @@ - - - dlgSettings - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 469 - 366 - - - - Settings - - - true - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - 0 - - - - - 20 - - - 15 - - - 20 - - - 20 - - - - - Name - - - gameNameLineEdit - - - - - - - - - - Config ID - - - gameConfigIDLineEdit - - - - - - - true - - - - - - - Description - - - gameDescriptionLineEdit - - - - - - - - - - Newsfeed URL - - - gameNewsfeedLineEdit - - - - - - - - - - Game install directory. There should be a file called patchclient.dll here - - - Install Directory - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - gameDirLineEdit - - - - - - - - - Game install directory. There should be a file called patchclient.dll here - - - - - - - Select game install directory from the file browser - - - ... - - - - - - - - - - 0 - 0 - - - - Browse OneLauncher config/data directory for this game - - - Browse Config Directory - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - 20 - - - 15 - - - 20 - - - 20 - - - - - - - - Language - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - gameLanguageComboBox - - - - - - - - - - - - - - Enable high resolution game files. You may need to patch the game after enabling this - - - Hi-Res Graphics - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - highResCheckBox - - - - - - - - 0 - 0 - - - - Enable high resolution game files. You may need to patch the game after enabling this - - - - - - - - - - Game client version to use. 64-bit is the most modern. It does work with WINE - - - Client Type - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - clientTypeComboBox - - - - - - - Game client version to use. 64-bit is the most modern. It does work with WINE - - - - - - - Standard launcher filename - - - Standard Launcher - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - standardLauncherLineEdit - - - - - - - Standard launcher filename - - - - - - - - 0 - 0 - - - - Run Standard Game Launcher - - - QToolButton::ToolButtonPopupMode::MenuButtonPopup - - - - - - - Patch client DLL filename - - - Patch Client DLL - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - patchClientLineEdit - - - - - - - Patch client DLL filename - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - <html><head/><body><p>The folder where user preferences, screenshots, and addons are stored. <span style=" font-weight:700;">Changing this does not move your existing files. It also won't take affect when using the official game launcher.</span></p></body></html> - - - Settings Directory - - - gameSettingsDirLineEdit - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - <html><head/><body><p>The folder where user preferences, screenshots, and addons are stored. <span style=" font-weight:700;">Changing this does not move your existing files. It also won't take affect when using the official game launcher.</span></p></body></html> - - - - - - - Select settings folder from filesystem - - - ... - - - - - - - - - - - - 20 - - - 15 - - - 20 - - - 20 - - - - - Auto Manage Wine - - - autoManageWineCheckBox - - - - - - - - 0 - 0 - - - - - - - - Path to WINE prefix - - - Wine Prefix - - - winePrefixLineEdit - - - - - - - Path to WINE prefix - - - true - - - - - - - Path to WINE executable - - - Wine Executable - - - wineExecutableLineEdit - - - - - - - Path to WINE executable - - - true - - - - - - - Value for the WINEDEBUG environment variable - - - WINEDEBUG - - - wineDebugLineEdit - - - - - - - Value for the WINEDEBUG environment variable - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - QFormLayout::RowWrapPolicy::WrapLongRows - - - 20 - - - 15 - - - 20 - - - 20 - - - - - Default language to use for games - - - Default Language - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - defaultLanguageComboBox - - - - - - - - 0 - 0 - - - - Default language to use for games - - - - - - - Use the default language for OneLauncher even when the current game is set to a different language - - - Always Use Default Language For UI - - - Qt::AlignmentFlag::AlignCenter - - - true - - - defaultLanguageForUICheckBox - - - - - - - - 0 - 0 - - - - Use the default language for OneLauncher even when the current game is set to a different language - - - - - - - - - - Games Sorting Mode - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - gamesSortingModeComboBox - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - Manage Games - - - - - - - - 0 - 0 - - - - Run Setup Wizard - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - - - 9 - - - 9 - - - 9 - - - 9 - - - - - <html><head/><body><p>Enable advanced options</p></body></html> - - - Advanced Options - - - true - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Orientation::Horizontal - - - QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Save - - - - - - - - - Run with patching disabled - - - Run launcher using "-skiprawdownload" and "-disablepatch" arguments - - - - - - QDialogWithStylePreview - QDialog -
.qtdesigner.custom_widgets
- 1 -
- - FramelessQDialogWithStylePreview - QDialogWithStylePreview -
.custom_widgets
- 1 -
- - QTabBar - QWidget -
qtabbar.h
- 1 -
- - FixedWordWrapQLabel - QLabel -
.custom_widgets
-
-
- - - - settingsButtonBox - rejected() - dlgSettings - reject() - - - 316 - 260 - - - 286 - 274 - - - - -
diff --git a/src/onelauncher/ui/settings_window.ui b/src/onelauncher/ui/settings_window.ui new file mode 100644 index 00000000..8aa66e13 --- /dev/null +++ b/src/onelauncher/ui/settings_window.ui @@ -0,0 +1,804 @@ + + + settingsWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 469 + 366 + + + + Settings + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 0 + + + + + 20 + + + 15 + + + 20 + + + 20 + + + + + Name + + + gameNameLineEdit + + + + + + + + + + Config ID + + + gameConfigIDLineEdit + + + + + + + true + + + + + + + Description + + + gameDescriptionLineEdit + + + + + + + + + + Newsfeed URL + + + gameNewsfeedLineEdit + + + + + + + + + + Game install directory. There should be a file + called patchclient.dll here + + + Install Directory + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + gameDirLineEdit + + + + + + + + + Game install directory. There should be a + file called patchclient.dll here + + + + + + + Select game install directory from the file + browser + + + ... + + + + + + + + + + 0 + 0 + + + + Browse OneLauncher config/data directory for this + game + + + Browse Config Directory + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + 20 + + + 15 + + + 20 + + + 20 + + + + + + + + Language + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + gameLanguageComboBox + + + + + + + + + + + + + + Enable high resolution game files. You may need to + patch the game after enabling this + + + Hi-Res Graphics + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + highResCheckBox + + + + + + + + 0 + 0 + + + + Enable high resolution game files. You may need to + patch the game after enabling this + + + + + + + + + + Game client version to use. 64-bit is the most + modern. It does work with WINE + + + Client Type + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + clientTypeComboBox + + + + + + + Game client version to use. 64-bit is the most + modern. It does work with WINE + + + + + + + Standard launcher filename + + + Standard Launcher + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + standardLauncherLineEdit + + + + + + + Standard launcher filename + + + + + + + + 0 + 0 + + + + Run Standard Game Launcher + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + + + + + Patch client DLL filename + + + Patch Client DLL + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + patchClientLineEdit + + + + + + + Patch client DLL filename + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + <html><head/><body><p>The + folder where user preferences, screenshots, and addons + are stored. <span style=" + font-weight:700;">Changing this does not move + your existing files. It also won't take affect when + using the official game + launcher.</span></p></body></html> + + + Settings Directory + + + gameSettingsDirLineEdit + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + <html><head/><body><p>The + folder where user preferences, screenshots, + and addons are stored. <span style=" + font-weight:700;">Changing this does + not move your existing files. It also won't + take affect when using the official game + launcher.</span></p></body></html> + + + + + + + Select settings folder from filesystem + + + ... + + + + + + + + + + + + 20 + + + 15 + + + 20 + + + 20 + + + + + Auto Manage Wine + + + autoManageWineCheckBox + + + + + + + + 0 + 0 + + + + + + + + Path to WINE prefix + + + Wine Prefix + + + winePrefixLineEdit + + + + + + + Path to WINE prefix + + + true + + + + + + + Path to WINE executable + + + Wine Executable + + + wineExecutableLineEdit + + + + + + + Path to WINE executable + + + true + + + + + + + Value for the WINEDEBUG environment variable + + + WINEDEBUG + + + wineDebugLineEdit + + + + + + + Value for the WINEDEBUG environment variable + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + QFormLayout::RowWrapPolicy::WrapLongRows + + + 20 + + + 15 + + + 20 + + + 20 + + + + + Default language to use for games + + + Default Language + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + defaultLanguageComboBox + + + + + + + + 0 + 0 + + + + Default language to use for games + + + + + + + Use the default language for OneLauncher even when + the current game is set to a different language + + + Always Use Default Language For UI + + + Qt::AlignmentFlag::AlignCenter + + + true + + + defaultLanguageForUICheckBox + + + + + + + + 0 + 0 + + + + Use the default language for OneLauncher even when + the current game is set to a different language + + + + + + + + + + Games Sorting Mode + + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + gamesSortingModeComboBox + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + Manage Games + + + + + + + + 0 + 0 + + + + Run Setup Wizard + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + 9 + + + 9 + + + 9 + + + 9 + + + + + <html><head/><body><p>Enable + advanced options</p></body></html> + + + Advanced Options + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Save + + + + + + + + + Run with patching disabled + + + Run launcher using "-skiprawdownload" and + "-disablepatch" arguments + + + + + + QDialogWithStylePreview + QDialog +
.qtdesigner.custom_widgets
+ 1 +
+ + FramelessQDialogWithStylePreview + QDialogWithStylePreview +
.custom_widgets
+ 1 +
+ + QTabBar + QWidget +
qtabbar.h
+ 1 +
+ + FixedWordWrapQLabel + QLabel +
.custom_widgets
+
+
+ + + + settingsButtonBox + rejected() + settingsWindow + reject() + + + 316 + 260 + + + 286 + 274 + + + + +
\ No newline at end of file diff --git a/src/onelauncher/ui/settings_uic.py b/src/onelauncher/ui/settings_window_uic.py similarity index 83% rename from src/onelauncher/ui/settings_uic.py rename to src/onelauncher/ui/settings_window_uic.py index 5538bf07..b89dd25f 100644 --- a/src/onelauncher/ui/settings_uic.py +++ b/src/onelauncher/ui/settings_window_uic.py @@ -25,25 +25,25 @@ from .custom_widgets import (FixedWordWrapQLabel, FramelessQDialogWithStylePreview) from .qtdesigner.custom_widgets import QDialogWithStylePreview -class Ui_dlgSettings(object): - def setupUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: - if not dlgSettings.objectName(): - dlgSettings.setObjectName(u"dlgSettings") - dlgSettings.setWindowModality(Qt.WindowModality.ApplicationModal) - dlgSettings.resize(469, 366) - dlgSettings.setModal(True) - self.actionRunStandardGameLauncherWithPatchingDisabled = QAction(dlgSettings) +class Ui_settingsWindow(object): + def setupUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: + if not settingsWindow.objectName(): + settingsWindow.setObjectName(u"settingsWindow") + settingsWindow.setWindowModality(Qt.WindowModality.ApplicationModal) + settingsWindow.resize(469, 366) + settingsWindow.setModal(True) + self.actionRunStandardGameLauncherWithPatchingDisabled = QAction(settingsWindow) self.actionRunStandardGameLauncherWithPatchingDisabled.setObjectName(u"actionRunStandardGameLauncherWithPatchingDisabled") - self.verticalLayout = QVBoxLayout(dlgSettings) + self.verticalLayout = QVBoxLayout(settingsWindow) self.verticalLayout.setSpacing(0) self.verticalLayout.setObjectName(u"verticalLayout") self.verticalLayout.setContentsMargins(0, 0, 0, 0) - self.tabBar = QTabBar(dlgSettings) + self.tabBar = QTabBar(settingsWindow) self.tabBar.setObjectName(u"tabBar") self.verticalLayout.addWidget(self.tabBar) - self.stackedWidget = QStackedWidget(dlgSettings) + self.stackedWidget = QStackedWidget(settingsWindow) self.stackedWidget.setObjectName(u"stackedWidget") self.pageGameInfo = QWidget() self.pageGameInfo.setObjectName(u"pageGameInfo") @@ -353,7 +353,7 @@ def setupUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: self.horizontalLayout = QHBoxLayout() self.horizontalLayout.setObjectName(u"horizontalLayout") self.horizontalLayout.setContentsMargins(9, 9, 9, 9) - self.showAdvancedSettingsCheckbox = QCheckBox(dlgSettings) + self.showAdvancedSettingsCheckbox = QCheckBox(settingsWindow) self.showAdvancedSettingsCheckbox.setObjectName(u"showAdvancedSettingsCheckbox") self.showAdvancedSettingsCheckbox.setChecked(True) @@ -363,7 +363,7 @@ def setupUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: self.horizontalLayout.addItem(self.horizontalSpacer) - self.settingsButtonBox = QDialogButtonBox(dlgSettings) + self.settingsButtonBox = QDialogButtonBox(settingsWindow) self.settingsButtonBox.setObjectName(u"settingsButtonBox") self.settingsButtonBox.setOrientation(Qt.Orientation.Horizontal) self.settingsButtonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel|QDialogButtonBox.StandardButton.Save) @@ -394,131 +394,131 @@ def setupUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: self.gamesSortingModeLabel.setBuddy(self.gamesSortingModeComboBox) #endif // QT_CONFIG(shortcut) - self.retranslateUi(dlgSettings) - self.settingsButtonBox.rejected.connect(dlgSettings.reject) + self.retranslateUi(settingsWindow) + self.settingsButtonBox.rejected.connect(settingsWindow.reject) self.stackedWidget.setCurrentIndex(0) - QMetaObject.connectSlotsByName(dlgSettings) + QMetaObject.connectSlotsByName(settingsWindow) # setupUi - def retranslateUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: - dlgSettings.setWindowTitle(QCoreApplication.translate("dlgSettings", u"Settings", None)) - self.actionRunStandardGameLauncherWithPatchingDisabled.setText(QCoreApplication.translate("dlgSettings", u"Run with patching disabled", None)) + def retranslateUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: + settingsWindow.setWindowTitle(QCoreApplication.translate("settingsWindow", u"Settings", None)) + self.actionRunStandardGameLauncherWithPatchingDisabled.setText(QCoreApplication.translate("settingsWindow", u"Run with patching disabled", None)) #if QT_CONFIG(tooltip) - self.actionRunStandardGameLauncherWithPatchingDisabled.setToolTip(QCoreApplication.translate("dlgSettings", u"Run launcher using \"-skiprawdownload\" and \"-disablepatch\" arguments", None)) + self.actionRunStandardGameLauncherWithPatchingDisabled.setToolTip(QCoreApplication.translate("settingsWindow", u"Run launcher using \"-skiprawdownload\" and \"-disablepatch\" arguments", None)) #endif // QT_CONFIG(tooltip) - self.gameNameLabel.setText(QCoreApplication.translate("dlgSettings", u"Name", None)) - self.gameConfigIDLabel.setText(QCoreApplication.translate("dlgSettings", u"Config ID", None)) - self.gameDescriptionLabel.setText(QCoreApplication.translate("dlgSettings", u"Description", None)) - self.gameNewsfeedLabel.setText(QCoreApplication.translate("dlgSettings", u"Newsfeed URL", None)) + self.gameNameLabel.setText(QCoreApplication.translate("settingsWindow", u"Name", None)) + self.gameConfigIDLabel.setText(QCoreApplication.translate("settingsWindow", u"Config ID", None)) + self.gameDescriptionLabel.setText(QCoreApplication.translate("settingsWindow", u"Description", None)) + self.gameNewsfeedLabel.setText(QCoreApplication.translate("settingsWindow", u"Newsfeed URL", None)) #if QT_CONFIG(tooltip) - self.gameDirLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Game install directory. There should be a file called patchclient.dll here", None)) + self.gameDirLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Game install directory. There should be a file called patchclient.dll here", None)) #endif // QT_CONFIG(tooltip) - self.gameDirLabel.setText(QCoreApplication.translate("dlgSettings", u"Install Directory", None)) + self.gameDirLabel.setText(QCoreApplication.translate("settingsWindow", u"Install Directory", None)) #if QT_CONFIG(tooltip) - self.gameDirLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Game install directory. There should be a file called patchclient.dll here", None)) + self.gameDirLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Game install directory. There should be a file called patchclient.dll here", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameDirButton.setToolTip(QCoreApplication.translate("dlgSettings", u"Select game install directory from the file browser", None)) + self.gameDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select game install directory from the file browser", None)) #endif // QT_CONFIG(tooltip) - self.gameDirButton.setText(QCoreApplication.translate("dlgSettings", u"...", None)) + self.gameDirButton.setText(QCoreApplication.translate("settingsWindow", u"...", None)) #if QT_CONFIG(tooltip) - self.browseGameConfigDirButton.setToolTip(QCoreApplication.translate("dlgSettings", u"Browse OneLauncher config/data directory for this game", None)) + self.browseGameConfigDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Browse OneLauncher config/data directory for this game", None)) #endif // QT_CONFIG(tooltip) - self.browseGameConfigDirButton.setText(QCoreApplication.translate("dlgSettings", u"Browse Config Directory", None)) + self.browseGameConfigDirButton.setText(QCoreApplication.translate("settingsWindow", u"Browse Config Directory", None)) #if QT_CONFIG(tooltip) self.gameLanguageLabel.setToolTip("") #endif // QT_CONFIG(tooltip) - self.gameLanguageLabel.setText(QCoreApplication.translate("dlgSettings", u"Language", None)) + self.gameLanguageLabel.setText(QCoreApplication.translate("settingsWindow", u"Language", None)) #if QT_CONFIG(tooltip) self.gameLanguageComboBox.setToolTip("") #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.highResLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Enable high resolution game files. You may need to patch the game after enabling this", None)) + self.highResLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Enable high resolution game files. You may need to patch the game after enabling this", None)) #endif // QT_CONFIG(tooltip) - self.highResLabel.setText(QCoreApplication.translate("dlgSettings", u"Hi-Res Graphics", None)) + self.highResLabel.setText(QCoreApplication.translate("settingsWindow", u"Hi-Res Graphics", None)) #if QT_CONFIG(tooltip) - self.highResCheckBox.setToolTip(QCoreApplication.translate("dlgSettings", u"Enable high resolution game files. You may need to patch the game after enabling this", None)) + self.highResCheckBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Enable high resolution game files. You may need to patch the game after enabling this", None)) #endif // QT_CONFIG(tooltip) self.highResCheckBox.setText("") #if QT_CONFIG(tooltip) - self.clientLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Game client version to use. 64-bit is the most modern. It does work with WINE", None)) + self.clientLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Game client version to use. 64-bit is the most modern. It does work with WINE", None)) #endif // QT_CONFIG(tooltip) - self.clientLabel.setText(QCoreApplication.translate("dlgSettings", u"Client Type", None)) + self.clientLabel.setText(QCoreApplication.translate("settingsWindow", u"Client Type", None)) #if QT_CONFIG(tooltip) - self.clientTypeComboBox.setToolTip(QCoreApplication.translate("dlgSettings", u"Game client version to use. 64-bit is the most modern. It does work with WINE", None)) + self.clientTypeComboBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Game client version to use. 64-bit is the most modern. It does work with WINE", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.standardLauncherLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Standard launcher filename", None)) + self.standardLauncherLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Standard launcher filename", None)) #endif // QT_CONFIG(tooltip) - self.standardLauncherLabel.setText(QCoreApplication.translate("dlgSettings", u"Standard Launcher", None)) + self.standardLauncherLabel.setText(QCoreApplication.translate("settingsWindow", u"Standard Launcher", None)) #if QT_CONFIG(tooltip) - self.standardLauncherLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Standard launcher filename", None)) + self.standardLauncherLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Standard launcher filename", None)) #endif // QT_CONFIG(tooltip) - self.standardGameLauncherButton.setText(QCoreApplication.translate("dlgSettings", u"Run Standard Game Launcher", None)) + self.standardGameLauncherButton.setText(QCoreApplication.translate("settingsWindow", u"Run Standard Game Launcher", None)) #if QT_CONFIG(tooltip) - self.patchClientLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Patch client DLL filename", None)) + self.patchClientLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Patch client DLL filename", None)) #endif // QT_CONFIG(tooltip) - self.patchClientLabel.setText(QCoreApplication.translate("dlgSettings", u"Patch Client DLL", None)) + self.patchClientLabel.setText(QCoreApplication.translate("settingsWindow", u"Patch Client DLL", None)) #if QT_CONFIG(tooltip) - self.patchClientLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Patch client DLL filename", None)) + self.patchClientLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Patch client DLL filename", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameSettingsDirLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"

The folder where user preferences, screenshots, and addons are stored. Changing this does not move your existing files. It also won't take affect when using the official game launcher.

", None)) + self.gameSettingsDirLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"

The folder where user preferences, screenshots, and addons are stored. Changing this does not move your existing files. It also won't take affect when using the official game launcher.

", None)) #endif // QT_CONFIG(tooltip) - self.gameSettingsDirLabel.setText(QCoreApplication.translate("dlgSettings", u"Settings Directory", None)) + self.gameSettingsDirLabel.setText(QCoreApplication.translate("settingsWindow", u"Settings Directory", None)) #if QT_CONFIG(tooltip) - self.gameSettingsDirLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"

The folder where user preferences, screenshots, and addons are stored. Changing this does not move your existing files. It also won't take affect when using the official game launcher.

", None)) + self.gameSettingsDirLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"

The folder where user preferences, screenshots, and addons are stored. Changing this does not move your existing files. It also won't take affect when using the official game launcher.

", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameSettingsDirButton.setToolTip(QCoreApplication.translate("dlgSettings", u"Select settings folder from filesystem", None)) + self.gameSettingsDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select settings folder from filesystem", None)) #endif // QT_CONFIG(tooltip) - self.gameSettingsDirButton.setText(QCoreApplication.translate("dlgSettings", u"...", None)) - self.autoManageWineLabel.setText(QCoreApplication.translate("dlgSettings", u"Auto Manage Wine", None)) + self.gameSettingsDirButton.setText(QCoreApplication.translate("settingsWindow", u"...", None)) + self.autoManageWineLabel.setText(QCoreApplication.translate("settingsWindow", u"Auto Manage Wine", None)) #if QT_CONFIG(tooltip) - self.winePrefixLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE prefix", None)) + self.winePrefixLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE prefix", None)) #endif // QT_CONFIG(tooltip) - self.winePrefixLabel.setText(QCoreApplication.translate("dlgSettings", u"Wine Prefix", None)) + self.winePrefixLabel.setText(QCoreApplication.translate("settingsWindow", u"Wine Prefix", None)) #if QT_CONFIG(tooltip) - self.winePrefixLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE prefix", None)) + self.winePrefixLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE prefix", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.wineExecutableLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE executable", None)) + self.wineExecutableLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE executable", None)) #endif // QT_CONFIG(tooltip) - self.wineExecutableLabel.setText(QCoreApplication.translate("dlgSettings", u"Wine Executable", None)) + self.wineExecutableLabel.setText(QCoreApplication.translate("settingsWindow", u"Wine Executable", None)) #if QT_CONFIG(tooltip) - self.wineExecutableLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE executable", None)) + self.wineExecutableLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE executable", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.wineDebugLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Value for the WINEDEBUG environment variable", None)) + self.wineDebugLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Value for the WINEDEBUG environment variable", None)) #endif // QT_CONFIG(tooltip) - self.wineDebugLabel.setText(QCoreApplication.translate("dlgSettings", u"WINEDEBUG", None)) + self.wineDebugLabel.setText(QCoreApplication.translate("settingsWindow", u"WINEDEBUG", None)) #if QT_CONFIG(tooltip) - self.wineDebugLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Value for the WINEDEBUG environment variable", None)) + self.wineDebugLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Value for the WINEDEBUG environment variable", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.defaultLanguageLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Default language to use for games", None)) + self.defaultLanguageLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Default language to use for games", None)) #endif // QT_CONFIG(tooltip) - self.defaultLanguageLabel.setText(QCoreApplication.translate("dlgSettings", u"Default Language", None)) + self.defaultLanguageLabel.setText(QCoreApplication.translate("settingsWindow", u"Default Language", None)) #if QT_CONFIG(tooltip) - self.defaultLanguageComboBox.setToolTip(QCoreApplication.translate("dlgSettings", u"Default language to use for games", None)) + self.defaultLanguageComboBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Default language to use for games", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.defaultLanguageForUILabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Use the default language for OneLauncher even when the current game is set to a different language", None)) + self.defaultLanguageForUILabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Use the default language for OneLauncher even when the current game is set to a different language", None)) #endif // QT_CONFIG(tooltip) - self.defaultLanguageForUILabel.setText(QCoreApplication.translate("dlgSettings", u"Always Use Default Language For UI", None)) + self.defaultLanguageForUILabel.setText(QCoreApplication.translate("settingsWindow", u"Always Use Default Language For UI", None)) #if QT_CONFIG(tooltip) - self.defaultLanguageForUICheckBox.setToolTip(QCoreApplication.translate("dlgSettings", u"Use the default language for OneLauncher even when the current game is set to a different language", None)) + self.defaultLanguageForUICheckBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Use the default language for OneLauncher even when the current game is set to a different language", None)) #endif // QT_CONFIG(tooltip) self.defaultLanguageForUICheckBox.setText("") - self.gamesSortingModeLabel.setText(QCoreApplication.translate("dlgSettings", u"Games Sorting Mode", None)) - self.gamesManagementButton.setText(QCoreApplication.translate("dlgSettings", u"Manage Games", None)) - self.setupWizardButton.setText(QCoreApplication.translate("dlgSettings", u"Run Setup Wizard", None)) + self.gamesSortingModeLabel.setText(QCoreApplication.translate("settingsWindow", u"Games Sorting Mode", None)) + self.gamesManagementButton.setText(QCoreApplication.translate("settingsWindow", u"Manage Games", None)) + self.setupWizardButton.setText(QCoreApplication.translate("settingsWindow", u"Run Setup Wizard", None)) #if QT_CONFIG(tooltip) - self.showAdvancedSettingsCheckbox.setToolTip(QCoreApplication.translate("dlgSettings", u"

Enable advanced options

", None)) + self.showAdvancedSettingsCheckbox.setToolTip(QCoreApplication.translate("settingsWindow", u"

Enable advanced options

", None)) #endif // QT_CONFIG(tooltip) - self.showAdvancedSettingsCheckbox.setText(QCoreApplication.translate("dlgSettings", u"Advanced Options", None)) + self.showAdvancedSettingsCheckbox.setText(QCoreApplication.translate("settingsWindow", u"Advanced Options", None)) # retranslateUi diff --git a/src/onelauncher/ui/setup_wizard.ui b/src/onelauncher/ui/setup_wizard.ui deleted file mode 100644 index 5e15d894..00000000 --- a/src/onelauncher/ui/setup_wizard.ui +++ /dev/null @@ -1,322 +0,0 @@ - - - Wizard - - - - 0 - 0 - 621 - 411 - - - - Wizard - - - - OneLauncher Setup Wizard: - - - This wizard will quickly take you through the steps needed to get up and running with OneLauncher. - - - 0 - - - - - - - - The language used for games by default - - - Default Language - - - Qt::AlignmentFlag::AlignCenter - - - languagesListWidget - - - - - - - - 0 - 0 - - - - The language used for games by default - - - QFrame::Shape::Box - - - QAbstractItemView::EditTrigger::CurrentChanged|QAbstractItemView::EditTrigger::DoubleClicked|QAbstractItemView::EditTrigger::EditKeyPressed|QAbstractItemView::EditTrigger::SelectedClicked - - - false - - - true - - - true - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - QFormLayout::RowWrapPolicy::WrapLongRows - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop - - - 12 - - - - - Always show OneLauncher interface in default language - - - Always Use Default Language For UI - - - alwaysUseDefaultLangForUICheckBox - - - - - - - - - - - - - Games Selection - - - Select your game installations. The first one will be the main game instance. - - - 1 - - - - - - - 0 - 0 - - - - true - - - QAbstractItemView::DragDropMode::InternalMove - - - Qt::DropAction::TargetMoveAction - - - true - - - QAbstractItemView::SelectionMode::SingleSelection - - - QAbstractItemView::SelectionBehavior::SelectItems - - - - icon-xl - - - - - - - - true - - - - - - - - - - - - Decrease priority - - - - - - - - - - Increase priority - - - - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - Select an existing game directory from the file browser - - - Add Existing Game - - - - - - - Create a new game installation - - - Install New Game - - - - - - - - - - Existing Games Data - - - Some of your game installations are already registered with OneLauncher. You can choose to have their settings and accounts either kept or reset. Unselected games are always removed. - - - 2 - - - - - - What should happen to existing game data? - - - - - - Keep it - - - gamesDataButtonGroup - - - - - - - Reset it - - - gamesDataButtonGroup - - - - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - false - - - true - - - QAbstractItemView::SelectionMode::NoSelection - - - - icon-xl - - - - - - - - - Setup Finished - - - That's it! You can always check out the settings menu or addons manager for extra customization. - - - 3 - - - - - - - - - diff --git a/src/onelauncher/ui/setup_wizard_window.ui b/src/onelauncher/ui/setup_wizard_window.ui new file mode 100644 index 00000000..ba3269c5 --- /dev/null +++ b/src/onelauncher/ui/setup_wizard_window.ui @@ -0,0 +1,329 @@ + + + setupWizardWindow + + + + 0 + 0 + 621 + 411 + + + + Setup Wizard + + + + OneLauncher Setup Wizard: + + + This wizard will quickly take you through the steps needed to get up and + running with OneLauncher. + + + 0 + + + + + + + + The language used for games by default + + + Default Language + + + Qt::AlignmentFlag::AlignCenter + + + languagesListWidget + + + + + + + + 0 + 0 + + + + The language used for games by default + + + QFrame::Shape::Box + + + + QAbstractItemView::EditTrigger::CurrentChanged|QAbstractItemView::EditTrigger::DoubleClicked|QAbstractItemView::EditTrigger::EditKeyPressed|QAbstractItemView::EditTrigger::SelectedClicked + + + false + + + true + + + true + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + QFormLayout::RowWrapPolicy::WrapLongRows + + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignTop + + + 12 + + + + + Always show OneLauncher interface in default language + + + Always Use Default Language For UI + + + alwaysUseDefaultLangForUICheckBox + + + + + + + + + + + + + Games Selection + + + Select your game installations. The first one will be the main game + instance. + + + 1 + + + + + + + 0 + 0 + + + + true + + + QAbstractItemView::DragDropMode::InternalMove + + + Qt::DropAction::TargetMoveAction + + + true + + + QAbstractItemView::SelectionMode::SingleSelection + + + QAbstractItemView::SelectionBehavior::SelectItems + + + + icon-xl + + + + + + + + true + + + + + + + + + + + + Decrease priority + + + + + + + + + + Increase priority + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Select an existing game directory from the file browser + + + Add Existing Game + + + + + + + Create a new game installation + + + Install New Game + + + + + + + + + + Existing Games Data + + + Some of your game installations are already registered with OneLauncher. You + can choose to have their settings and accounts either kept or reset. Unselected + games are always removed. + + + 2 + + + + + + What should happen to existing game data? + + + + + + Keep it + + + gamesDataButtonGroup + + + + + + + Reset it + + + gamesDataButtonGroup + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + false + + + true + + + QAbstractItemView::SelectionMode::NoSelection + + + + icon-xl + + + + + + + + + Setup Finished + + + That's it! You can always check out the settings menu or addons manager for + extra customization. + + + 3 + + + + + + + + + \ No newline at end of file diff --git a/src/onelauncher/ui/setup_wizard_uic.py b/src/onelauncher/ui/setup_wizard_window_uic.py similarity index 77% rename from src/onelauncher/ui/setup_wizard_uic.py rename to src/onelauncher/ui/setup_wizard_window_uic.py index 70efb855..9070517d 100644 --- a/src/onelauncher/ui/setup_wizard_uic.py +++ b/src/onelauncher/ui/setup_wizard_window_uic.py @@ -21,11 +21,11 @@ QPushButton, QRadioButton, QSizePolicy, QSpacerItem, QVBoxLayout, QWidget, QWizard, QWizardPage) -class Ui_Wizard(object): - def setupUi(self, Wizard: QWizard) -> None: - if not Wizard.objectName(): - Wizard.setObjectName(u"Wizard") - Wizard.resize(621, 411) +class Ui_setupWizardWindow(object): + def setupUi(self, setupWizardWindow: QWizard) -> None: + if not setupWizardWindow.objectName(): + setupWizardWindow.setObjectName(u"setupWizardWindow") + setupWizardWindow.resize(621, 411) self.languageSelectionWizardPage = QWizardPage() self.languageSelectionWizardPage.setObjectName(u"languageSelectionWizardPage") self.horizontalLayout_2 = QHBoxLayout(self.languageSelectionWizardPage) @@ -78,7 +78,7 @@ def setupUi(self, Wizard: QWizard) -> None: self.horizontalLayout_2.addLayout(self.formLayout) - Wizard.setPage(0, self.languageSelectionWizardPage) + setupWizardWindow.setPage(0, self.languageSelectionWizardPage) self.gamesSelectionWizardPage = QWizardPage() self.gamesSelectionWizardPage.setObjectName(u"gamesSelectionWizardPage") self.gamesSelectionPageLayout = QVBoxLayout(self.gamesSelectionWizardPage) @@ -134,7 +134,7 @@ def setupUi(self, Wizard: QWizard) -> None: self.gamesSelectionPageLayout.addLayout(self.horizontalLayout) - Wizard.setPage(1, self.gamesSelectionWizardPage) + setupWizardWindow.setPage(1, self.gamesSelectionWizardPage) self.dataDeletionWizardPage = QWizardPage() self.dataDeletionWizardPage.setObjectName(u"dataDeletionWizardPage") self.verticalLayout_2 = QVBoxLayout(self.dataDeletionWizardPage) @@ -144,7 +144,7 @@ def setupUi(self, Wizard: QWizard) -> None: self.horizontalLayout_3 = QHBoxLayout(self.groupBox) self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") self.keepDataRadioButton = QRadioButton(self.groupBox) - self.gamesDataButtonGroup = QButtonGroup(Wizard) + self.gamesDataButtonGroup = QButtonGroup(setupWizardWindow) self.gamesDataButtonGroup.setObjectName(u"gamesDataButtonGroup") self.gamesDataButtonGroup.addButton(self.keepDataRadioButton) self.keepDataRadioButton.setObjectName(u"keepDataRadioButton") @@ -172,64 +172,64 @@ def setupUi(self, Wizard: QWizard) -> None: self.verticalLayout_2.addWidget(self.gamesDeletionStatusListView) - Wizard.setPage(2, self.dataDeletionWizardPage) + setupWizardWindow.setPage(2, self.dataDeletionWizardPage) self.finishedWizardPage = QWizardPage() self.finishedWizardPage.setObjectName(u"finishedWizardPage") - Wizard.setPage(3, self.finishedWizardPage) + setupWizardWindow.setPage(3, self.finishedWizardPage) #if QT_CONFIG(shortcut) self.label.setBuddy(self.languagesListWidget) self.alwaysUseDefaultLangForUILabel.setBuddy(self.alwaysUseDefaultLangForUICheckBox) #endif // QT_CONFIG(shortcut) - self.retranslateUi(Wizard) + self.retranslateUi(setupWizardWindow) - QMetaObject.connectSlotsByName(Wizard) + QMetaObject.connectSlotsByName(setupWizardWindow) # setupUi - def retranslateUi(self, Wizard: QWizard) -> None: - Wizard.setWindowTitle(QCoreApplication.translate("Wizard", u"Wizard", None)) - self.languageSelectionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"OneLauncher Setup Wizard:", None)) - self.languageSelectionWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"This wizard will quickly take you through the steps needed to get up and running with OneLauncher. ", None)) + def retranslateUi(self, setupWizardWindow: QWizard) -> None: + setupWizardWindow.setWindowTitle(QCoreApplication.translate("setupWizardWindow", u"Setup Wizard", None)) + self.languageSelectionWizardPage.setTitle(QCoreApplication.translate("setupWizardWindow", u"OneLauncher Setup Wizard:", None)) + self.languageSelectionWizardPage.setSubTitle(QCoreApplication.translate("setupWizardWindow", u"This wizard will quickly take you through the steps needed to get up and running with OneLauncher. ", None)) #if QT_CONFIG(tooltip) - self.label.setToolTip(QCoreApplication.translate("Wizard", u"The language used for games by default", None)) + self.label.setToolTip(QCoreApplication.translate("setupWizardWindow", u"The language used for games by default", None)) #endif // QT_CONFIG(tooltip) - self.label.setText(QCoreApplication.translate("Wizard", u"Default Language", None)) + self.label.setText(QCoreApplication.translate("setupWizardWindow", u"Default Language", None)) #if QT_CONFIG(tooltip) - self.languagesListWidget.setToolTip(QCoreApplication.translate("Wizard", u"The language used for games by default", None)) + self.languagesListWidget.setToolTip(QCoreApplication.translate("setupWizardWindow", u"The language used for games by default", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.alwaysUseDefaultLangForUILabel.setToolTip(QCoreApplication.translate("Wizard", u"Always show OneLauncher interface in default language", None)) + self.alwaysUseDefaultLangForUILabel.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Always show OneLauncher interface in default language", None)) #endif // QT_CONFIG(tooltip) - self.alwaysUseDefaultLangForUILabel.setText(QCoreApplication.translate("Wizard", u"Always Use Default Language For UI", None)) - self.gamesSelectionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Games Selection", None)) - self.gamesSelectionWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"Select your game installations. The first one will be the main game instance.", None)) + self.alwaysUseDefaultLangForUILabel.setText(QCoreApplication.translate("setupWizardWindow", u"Always Use Default Language For UI", None)) + self.gamesSelectionWizardPage.setTitle(QCoreApplication.translate("setupWizardWindow", u"Games Selection", None)) + self.gamesSelectionWizardPage.setSubTitle(QCoreApplication.translate("setupWizardWindow", u"Select your game installations. The first one will be the main game instance.", None)) self.gamesListWidget.setProperty(u"qssClass", [ - QCoreApplication.translate("Wizard", u"icon-xl", None)]) + QCoreApplication.translate("setupWizardWindow", u"icon-xl", None)]) self.gamesDiscoveryStatusLabel.setText("") #if QT_CONFIG(tooltip) - self.downPriorityButton.setToolTip(QCoreApplication.translate("Wizard", u"Decrease priority", None)) + self.downPriorityButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Decrease priority", None)) #endif // QT_CONFIG(tooltip) - self.downPriorityButton.setText(QCoreApplication.translate("Wizard", u"\u2193", None)) + self.downPriorityButton.setText(QCoreApplication.translate("setupWizardWindow", u"\u2193", None)) #if QT_CONFIG(tooltip) - self.upPriorityButton.setToolTip(QCoreApplication.translate("Wizard", u"Increase priority", None)) + self.upPriorityButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Increase priority", None)) #endif // QT_CONFIG(tooltip) - self.upPriorityButton.setText(QCoreApplication.translate("Wizard", u"\u2191", None)) + self.upPriorityButton.setText(QCoreApplication.translate("setupWizardWindow", u"\u2191", None)) #if QT_CONFIG(tooltip) - self.addExistingGameButton.setToolTip(QCoreApplication.translate("Wizard", u"Select an existing game directory from the file browser", None)) + self.addExistingGameButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Select an existing game directory from the file browser", None)) #endif // QT_CONFIG(tooltip) - self.addExistingGameButton.setText(QCoreApplication.translate("Wizard", u"Add Existing Game", None)) + self.addExistingGameButton.setText(QCoreApplication.translate("setupWizardWindow", u"Add Existing Game", None)) #if QT_CONFIG(tooltip) - self.installGameButton.setToolTip(QCoreApplication.translate("Wizard", u"Create a new game installation", None)) + self.installGameButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Create a new game installation", None)) #endif // QT_CONFIG(tooltip) - self.installGameButton.setText(QCoreApplication.translate("Wizard", u"Install New Game", None)) - self.dataDeletionWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Existing Games Data", None)) - self.dataDeletionWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"Some of your game installations are already registered with OneLauncher. You can choose to have their settings and accounts either kept or reset. Unselected games are always removed.", None)) - self.groupBox.setTitle(QCoreApplication.translate("Wizard", u"What should happen to existing game data?", None)) - self.keepDataRadioButton.setText(QCoreApplication.translate("Wizard", u"Keep it", None)) - self.resetDataRadioButton.setText(QCoreApplication.translate("Wizard", u"Reset it", None)) + self.installGameButton.setText(QCoreApplication.translate("setupWizardWindow", u"Install New Game", None)) + self.dataDeletionWizardPage.setTitle(QCoreApplication.translate("setupWizardWindow", u"Existing Games Data", None)) + self.dataDeletionWizardPage.setSubTitle(QCoreApplication.translate("setupWizardWindow", u"Some of your game installations are already registered with OneLauncher. You can choose to have their settings and accounts either kept or reset. Unselected games are always removed.", None)) + self.groupBox.setTitle(QCoreApplication.translate("setupWizardWindow", u"What should happen to existing game data?", None)) + self.keepDataRadioButton.setText(QCoreApplication.translate("setupWizardWindow", u"Keep it", None)) + self.resetDataRadioButton.setText(QCoreApplication.translate("setupWizardWindow", u"Reset it", None)) self.gamesDeletionStatusListView.setProperty(u"qssClass", [ - QCoreApplication.translate("Wizard", u"icon-xl", None)]) - self.finishedWizardPage.setTitle(QCoreApplication.translate("Wizard", u"Setup Finished", None)) - self.finishedWizardPage.setSubTitle(QCoreApplication.translate("Wizard", u"That's it! You can always check out the settings menu or addons manager for extra customization.", None)) + QCoreApplication.translate("setupWizardWindow", u"icon-xl", None)]) + self.finishedWizardPage.setTitle(QCoreApplication.translate("setupWizardWindow", u"Setup Finished", None)) + self.finishedWizardPage.setSubTitle(QCoreApplication.translate("setupWizardWindow", u"That's it! You can always check out the settings menu or addons manager for extra customization.", None)) # retranslateUi From 469510236702f68b9425e50f6d66d41360c7699e Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 16:27:16 -0600 Subject: [PATCH 77/97] feat(start_game): set important `UserPreferences.ini` values --- src/onelauncher/game_utilities.py | 15 ++++++ src/onelauncher/start_game.py | 76 ++++++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/onelauncher/game_utilities.py b/src/onelauncher/game_utilities.py index cc8dac9c..7e530f68 100644 --- a/src/onelauncher/game_utilities.py +++ b/src/onelauncher/game_utilities.py @@ -66,3 +66,18 @@ def get_game_settings_dir( return game_config.game_settings_directory or get_default_game_settings_dir( launcher_local_config=launcher_local_config ) + + +def get_game_user_preferences_path( + game_config: GameConfig, game_launcher_local_config: GameLauncherLocalConfig +) -> CaseInsensitiveAbsolutePath: + """ + The config file used by the game. `UserPreferences.ini`. + The standard game launcher also stores config here under the `Launcher` section. + """ + game_settings_dir = get_game_settings_dir( + game_config=game_config, launcher_local_config=game_launcher_local_config + ) + # The filename "UserPreferences.ini" seems to be hardcoded into the launcher + # and client executables as the default. + return game_settings_dir / "UserPreferences.ini" diff --git a/src/onelauncher/start_game.py b/src/onelauncher/start_game.py index 204c9098..38272d73 100644 --- a/src/onelauncher/start_game.py +++ b/src/onelauncher/start_game.py @@ -1,8 +1,13 @@ +import configparser import logging import os import subprocess +import sys +from contextlib import suppress +from copy import deepcopy from datetime import UTC, datetime from functools import partial +from io import StringIO from pathlib import Path from types import MappingProxyType @@ -12,7 +17,7 @@ from onelauncher.async_utils import for_each_in_stream from onelauncher.config_manager import ConfigManager from onelauncher.game_launcher_local_config import GameLauncherLocalConfig -from onelauncher.game_utilities import get_game_settings_dir +from onelauncher.game_utilities import get_game_user_preferences_path from onelauncher.logs import ExternalProcessLogsFilter from .game_config import ClientType, GameConfig, GameConfigID @@ -133,15 +138,20 @@ async def get_launch_args( game_launcher_config.high_res_patch_arg or " --HighResOutOfDate" ) - # Setting the `--prefs` command configure both the game user preferences file and the + # Setting the `--prefs` command configures both the game user preferences file and the # game settings folder. The game settings folder is set to the folder of the # user preferences file passed to `--prefs`. - # The filename "UserPreferences.ini" seems to be hardcoded into the launcher - # and client executables as the default. - game_settings_dir = get_game_settings_dir( - game_config=game_config, launcher_local_config=game_launcher_local_config + launch_args.extend( + ( + "--prefs", + str( + get_game_user_preferences_path( + game_config=game_config, + game_launcher_local_config=game_launcher_local_config, + ) + ), + ) ) - launch_args.extend(("--prefs", str(game_settings_dir / "UserPreferences.ini"))) redacted_launch_args = tuple( arg.replace(account_number, "******").replace(ticket, "******") @@ -151,6 +161,52 @@ async def get_launch_args( return tuple(launch_args) +async def update_game_user_preferences( + game_config: GameConfig, game_launcher_local_config: GameLauncherLocalConfig +) -> None: + """ + Set important `UserPreferences.ini` values. + """ + if sys.platform == "win32": + return + + game_user_preferences_path = trio.Path( # type: ignore[unreachable,unused-ignore] + get_game_user_preferences_path( + game_config=game_config, + game_launcher_local_config=game_launcher_local_config, + ) + ) + config = configparser.ConfigParser(delimiters=("=",)) + with suppress(FileNotFoundError): + config.read_string(await game_user_preferences_path.read_text()) + unedited_config = deepcopy(config) + + # Set screen mode to `FullScreenWindowed` on macOS on first game launch. + # The default `Fullscreen` mode bloacks the macOS prompt for allowing required game + # permissions. It also causes issues with some Macintosh monitors and laptop screens, + # especially when using multiple monitors. + if sys.platform == "darwin" and game_config.last_played is None: + with suppress(configparser.DuplicateSectionError): + config.add_section("Display") + config["Display"]["FullScreen"] = "False" + config["Display"]["ScreenMode"] = "FullScreenWindowed" + + if game_config.wine.builtin_prefix_enabled and ( + sys.platform == "darwin" or "Render" not in config + ): + with suppress(configparser.DuplicateSectionError): + config.add_section("Render") + config["Render"]["D3DVersionPromptedForAtStartup"] = "11" + config["Render"]["GraphicsCore"] = "D3D11" + + if config == unedited_config: + return + with StringIO() as string_io: + config.write(string_io, space_around_delimiters=False) + string_io.seek(0) + await game_user_preferences_path.write_text(string_io.read()) + + async def start_game( config_manager: ConfigManager, game_id: GameConfigID, @@ -166,6 +222,12 @@ async def start_game( MissingLaunchArgumentError OSError: Error encountered starting or communicating with the process """ + # This must be called before `game_config.last_played` is updated. + await update_game_user_preferences( + game_config=config_manager.get_game_config(game_id), + game_launcher_local_config=game_launcher_local_config, + ) + # Game was last played right now. config_manager.update_game_config_file( game_id=game_id, From 5c49f69d11d3a02f4e1d6ac839923b927808ac59 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Wed, 24 Dec 2025 16:52:32 -0600 Subject: [PATCH 78/97] build(deps): update --- pyproject.toml | 4 +- src/onelauncher/addon_manager_window.py | 11 +- uv.lock | 219 +++++++++++++----------- 3 files changed, 127 insertions(+), 107 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ccf4444c..841cd4b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,9 +51,9 @@ onelauncher = "onelauncher.__main__:main" [dependency-groups] lint = [ - "mypy>=1.18.2", + "mypy>=1.19.1", "types-cachetools>=5.3.0.7", - "ruff>=0.14.1", + "ruff>=0.14.10", # Newer versions have more accurate types. "PySide6-Essentials>=6.10.0", "typos>=1.40.0", diff --git a/src/onelauncher/addon_manager_window.py b/src/onelauncher/addon_manager_window.py index b0044400..076ff725 100644 --- a/src/onelauncher/addon_manager_window.py +++ b/src/onelauncher/addon_manager_window.py @@ -54,6 +54,7 @@ from xml.dom import EMPTY_NAMESPACE from xml.dom.minicompat import NodeList from xml.dom.minidom import Element +from xml.parsers.expat import ExpatError import attrs import certifi @@ -509,7 +510,7 @@ def removeManagedPluginsFromList( for compendium_file in compendium_files: try: doc = defusedxml.minidom.parse(str(compendium_file)) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.warning( "`.plugincompendium` file has invalid XML: %s", compendium_file, @@ -575,7 +576,7 @@ def parseCompendiumFile(self, file: Path, tag: str) -> AddonInfo | None: try: doc = defusedxml.minidom.parse(str(file)) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.exception("Compendium file has invalid XML: %s", file) return None nodes = doc.getElementsByTagName(tag)[0].childNodes @@ -1582,7 +1583,7 @@ def uninstallPlugins( if self.checkAddonForDependencies(plugin, table): try: doc = defusedxml.minidom.parse(plugin[1]) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.warning( "`.plugincompendium` file has invalid XML: %s", plugin[1], @@ -1613,7 +1614,7 @@ def uninstallPlugins( if plugin_file.exists(): try: doc = defusedxml.minidom.parse(str(plugin_file)) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.warning( "`.plugin` file has invalid XML: %s", plugin_file, @@ -1870,7 +1871,7 @@ def getRemoteAddons( try: doc = defusedxml.minidom.parseString(addons_file_response.text) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.exception( "Addons feed has invalid XML. Please report this error if it continues." ) diff --git a/uv.lock b/uv.lock index 62c25f9c..fcffa37c 100644 --- a/uv.lock +++ b/uv.lock @@ -4,16 +4,15 @@ requires-python = "==3.11.*" [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] [[package]] @@ -80,11 +79,11 @@ tomlkit = [ [[package]] name = "certifi" -version = "2025.10.5" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] @@ -194,7 +193,7 @@ wheels = [ [[package]] name = "cyclopts" -version = "4.3.0" +version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -202,9 +201,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/0f/fe026df2ab8301e30a2b0bd425ff1462ad858fd4f991c1ac0389c2059c24/cyclopts-4.3.0.tar.gz", hash = "sha256:e95179cd0a959ce250ecfb2f0262a5996a92c1f9467bccad2f3d829e6833cef5", size = 151411, upload-time = "2025-11-25T02:59:33.572Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/99/e1b75193ee23bd10a05a3b90c065d419b1c8c18f61cae6b8218c7158f792/cyclopts-4.4.1.tar.gz", hash = "sha256:368a404926b46a49dc328a33ccd7e55ba879296a28e64a42afe2f6667704cecf", size = 159245, upload-time = "2025-12-21T13:59:02.266Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/e8/77a231ae531cf38765b75ddf27dae28bb5f70b41d8bb4f15ce1650e93f57/cyclopts-4.3.0-py3-none-any.whl", hash = "sha256:91a30b69faf128ada7cfeaefd7d9649dc222e8b2a8697f1fc99e4ee7b7ca44f3", size = 187184, upload-time = "2025-11-25T02:59:32.21Z" }, + { url = "https://files.pythonhosted.org/packages/8d/05/8efadba80e1296526e69c1dceba8b0f0bc3756e8d69f6ed9b0e647cf3169/cyclopts-4.4.1-py3-none-any.whl", hash = "sha256:67500e9fde90f335fddbf9c452d2e7c4f58209dffe52e7abb1e272796a963bde", size = 196726, upload-time = "2025-12-21T13:59:03.127Z" }, ] [[package]] @@ -227,11 +226,11 @@ wheels = [ [[package]] name = "docutils" -version = "0.22.2" +version = "0.22.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, ] [[package]] @@ -316,14 +315,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] @@ -358,26 +357,26 @@ wheels = [ [[package]] name = "jaraco-context" -version = "6.0.1" +version = "6.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-tarfile" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/7d/41acf8e22d791bde812cb6c2c36128bb932ed8ae066bcb5e39cb198e8253/jaraco_context-6.0.2.tar.gz", hash = "sha256:953ae8dddb57b1d791bf72ea1009b32088840a7dd19b9ba16443f62be919ee57", size = 14994, upload-time = "2025-12-24T19:21:35.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0c/1e0096ced9c55f9c6c6655446798df74165780375d3f5ab5f33751e087ae/jaraco_context-6.0.2-py3-none-any.whl", hash = "sha256:55fc21af4b4f9ca94aa643b6ee7fe13b1e4c01abf3aeb98ca4ad9c80b741c786", size = 6988, upload-time = "2025-12-24T19:21:34.557Z" }, ] [[package]] name = "jaraco-functools" -version = "4.3.0" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/ed/1aa2d585304ec07262e1a83a9889880701079dde796ac7b1d1826f40c63d/jaraco_functools-4.3.0.tar.gz", hash = "sha256:cfd13ad0dd2c47a3600b439ef72d8615d482cedcff1632930d6f28924d92f294", size = 19755, upload-time = "2025-08-18T20:05:09.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/09/726f168acad366b11e420df31bf1c702a54d373a83f968d94141a8c3fde0/jaraco_functools-4.3.0-py3-none-any.whl", hash = "sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8", size = 10408, upload-time = "2025-08-18T20:05:08.69Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, ] [[package]] @@ -391,7 +390,7 @@ wheels = [ [[package]] name = "keyring" -version = "25.6.0" +version = "25.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, @@ -402,9 +401,28 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "librt" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/e4/b59bdf1197fdf9888452ea4d2048cdad61aef85eb83e99dc52551d7fdc04/librt-0.7.4.tar.gz", hash = "sha256:3871af56c59864d5fd21d1ac001eb2fb3b140d52ba0454720f2e4a19812404ba", size = 145862, upload-time = "2025-12-15T16:52:43.862Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085, upload-time = "2024-12-25T15:26:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/84/64/44089b12d8b4714a7f0e2f33fb19285ba87702d4be0829f20b36ebeeee07/librt-0.7.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3485b9bb7dfa66167d5500ffdafdc35415b45f0da06c75eb7df131f3357b174a", size = 54709, upload-time = "2025-12-15T16:51:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/26/ef/6fa39fb5f37002f7d25e0da4f24d41b457582beea9369eeb7e9e73db5508/librt-0.7.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:188b4b1a770f7f95ea035d5bbb9d7367248fc9d12321deef78a269ebf46a5729", size = 56663, upload-time = "2025-12-15T16:51:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/cbaca170a13bee2469c90df9e47108610b4422c453aea1aec1779ac36c24/librt-0.7.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b668b1c840183e4e38ed5a99f62fac44c3a3eef16870f7f17cfdfb8b47550ed", size = 161703, upload-time = "2025-12-15T16:51:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/d0/32/0b2296f9cc7e693ab0d0835e355863512e5eac90450c412777bd699c76ae/librt-0.7.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e8f864b521f6cfedb314d171630f827efee08f5c3462bcbc2244ab8e1768cd6", size = 171027, upload-time = "2025-12-15T16:51:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/c70b6d40f7342716e5f1353c8da92d9e32708a18cbfa44897a93ec2bf879/librt-0.7.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df7c9def4fc619a9c2ab402d73a0c5b53899abe090e0100323b13ccb5a3dd82", size = 184700, upload-time = "2025-12-15T16:51:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c8/555c405155da210e4c4113a879d378f54f850dbc7b794e847750a8fadd43/librt-0.7.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f79bc3595b6ed159a1bf0cdc70ed6ebec393a874565cab7088a219cca14da727", size = 180719, upload-time = "2025-12-15T16:51:23.561Z" }, + { url = "https://files.pythonhosted.org/packages/6b/88/34dc1f1461c5613d1b73f0ecafc5316cc50adcc1b334435985b752ed53e5/librt-0.7.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77772a4b8b5f77d47d883846928c36d730b6e612a6388c74cba33ad9eb149c11", size = 174535, upload-time = "2025-12-15T16:51:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/b6/5a/f3fafe80a221626bcedfa9fe5abbf5f04070989d44782f579b2d5920d6d0/librt-0.7.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:064a286e6ab0b4c900e228ab4fa9cb3811b4b83d3e0cc5cd816b2d0f548cb61c", size = 195236, upload-time = "2025-12-15T16:51:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/d8/77/5c048d471ce17f4c3a6e08419be19add4d291e2f7067b877437d482622ac/librt-0.7.4-cp311-cp311-win32.whl", hash = "sha256:42da201c47c77b6cc91fc17e0e2b330154428d35d6024f3278aa2683e7e2daf2", size = 42930, upload-time = "2025-12-15T16:51:27.853Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3b/514a86305a12c3d9eac03e424b07cd312c7343a9f8a52719aa079590a552/librt-0.7.4-cp311-cp311-win_amd64.whl", hash = "sha256:d31acb5886c16ae1711741f22504195af46edec8315fe69b77e477682a87a83e", size = 49240, upload-time = "2025-12-15T16:51:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/3b7b1914f565926b780a734fac6e9a4d2c7aefe41f4e89357d73697a9457/librt-0.7.4-cp311-cp311-win_arm64.whl", hash = "sha256:114722f35093da080a333b3834fff04ef43147577ed99dd4db574b03a5f7d170", size = 42613, upload-time = "2025-12-15T16:51:30.194Z" }, ] [[package]] @@ -478,22 +496,23 @@ wheels = [ [[package]] name = "mypy" -version = "1.18.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, - { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] @@ -517,18 +536,18 @@ sdist = { url = "https://files.pythonhosted.org/packages/d8/fb/51df3b30b0f9b3e73 [[package]] name = "numpy" -version = "2.3.4" +version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/f4/098d2270d52b41f1bd7db9fc288aaa0400cb48c2a3e2af6fa365d9720947/numpy-2.3.4.tar.gz", hash = "sha256:a7d018bfedb375a8d979ac758b120ba846a7fe764911a64465fd87b8729f4a6a", size = 20582187, upload-time = "2025-10-15T16:18:11.77Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/e7/0e07379944aa8afb49a556a2b54587b828eb41dc9adc56fb7615b678ca53/numpy-2.3.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e78aecd2800b32e8347ce49316d3eaf04aed849cd5b38e0af39f829a4e59f5eb", size = 21259519, upload-time = "2025-10-15T16:15:19.012Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cb/5a69293561e8819b09e34ed9e873b9a82b5f2ade23dce4c51dc507f6cfe1/numpy-2.3.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7fd09cc5d65bda1e79432859c40978010622112e9194e581e3415a3eccc7f43f", size = 14452796, upload-time = "2025-10-15T16:15:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/ff11611200acd602a1e5129e36cfd25bf01ad8e5cf927baf2e90236eb02e/numpy-2.3.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1b219560ae2c1de48ead517d085bc2d05b9433f8e49d0955c82e8cd37bd7bf36", size = 5381639, upload-time = "2025-10-15T16:15:25.572Z" }, - { url = "https://files.pythonhosted.org/packages/ea/77/e95c757a6fe7a48d28a009267408e8aa382630cc1ad1db7451b3bc21dbb4/numpy-2.3.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:bafa7d87d4c99752d07815ed7a2c0964f8ab311eb8168f41b910bd01d15b6032", size = 6914296, upload-time = "2025-10-15T16:15:27.079Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b6/64898f51a86ec88ca1257a59c1d7fd077b60082a119affefcdf1dd0df8ca/numpy-2.3.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6e274603039f924c0fe5cb73438fa9246699c78a6df1bd3decef9ae592ae1c05", size = 21131552, upload-time = "2025-10-15T16:17:55.845Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4c/f135dc6ebe2b6a3c77f4e4838fa63d350f85c99462012306ada1bd4bc460/numpy-2.3.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d149aee5c72176d9ddbc6803aef9c0f6d2ceeea7626574fc68518da5476fa346", size = 14377796, upload-time = "2025-10-15T16:17:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a4/f33f9c23fcc13dd8412fc8614559b5b797e0aba9d8e01dfa8bae10c84004/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:6d34ed9db9e6395bb6cd33286035f73a59b058169733a9db9f85e650b88df37e", size = 5306904, upload-time = "2025-10-15T16:18:00.596Z" }, - { url = "https://files.pythonhosted.org/packages/28/af/c44097f25f834360f9fb960fa082863e0bad14a42f36527b2a121abdec56/numpy-2.3.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:fdebe771ca06bb8d6abce84e51dca9f7921fe6ad34a0c914541b063e9a68928b", size = 6819682, upload-time = "2025-10-15T16:18:02.32Z" }, + { url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" }, + { url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" }, + { url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" }, + { url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" }, + { url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" }, ] [[package]] @@ -627,21 +646,21 @@ dev = [ { name = "imageio", marker = "sys_platform == 'darwin'", specifier = ">=2.37.2" }, { name = "marko", specifier = ">=2.1.2" }, { name = "mypy" }, - { name = "mypy", specifier = ">=1.18.2" }, + { name = "mypy", specifier = ">=1.19.1" }, { name = "nuitka", specifier = ">=2.4.8" }, { name = "pyside6-essentials", specifier = ">=6.10.0" }, { name = "pytest", specifier = ">=8.3.2" }, { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-randomly", specifier = ">=3.15.0" }, { name = "pytest-trio", specifier = ">=0.8.0" }, - { name = "ruff", specifier = ">=0.14.1" }, + { name = "ruff", specifier = ">=0.14.10" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, { name = "typos", specifier = ">=1.40.0" }, ] lint = [ - { name = "mypy", specifier = ">=1.18.2" }, + { name = "mypy", specifier = ">=1.19.1" }, { name = "pyside6-essentials", specifier = ">=6.10.0" }, - { name = "ruff", specifier = ">=0.14.1" }, + { name = "ruff", specifier = ">=0.14.10" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, { name = "typos", specifier = ">=1.40.0" }, ] @@ -706,11 +725,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.5.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] @@ -724,11 +743,11 @@ wheels = [ [[package]] name = "pycocoa" -version = "25.4.8" +version = "25.12.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/37/b242899fa4852131f5df3f8e1aa25275a84e642e9352715d0ee8a7e89772/pycocoa-25.4.8.tar.gz", hash = "sha256:fbc66097abe55f63d082f513dc1ed8489736813b87b24a9ca6973860bdc73480", size = 557781, upload-time = "2025-04-08T16:41:02.285Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/f7/4d61927a6be0ce33129b35221751c844e925619f199567517dbf10377ba1/pycocoa-25.12.4.tar.gz", hash = "sha256:6b174e972da63657ddf1095e6504e4cad63e143046752b71aa496e14e8f27722", size = 558218, upload-time = "2025-12-03T21:11:24.551Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/0b/a814bd8f6776bfe57171b9e8785f8df134204721ca7d72d9e5abab84d889/pycocoa-25.4.8-py2.py3-none-any.whl", hash = "sha256:ba0c539981d79d6469c226323c94fe486b7732d5ef11e2bf5fdefef4e2de1c57", size = 227218, upload-time = "2025-04-08T16:41:04.122Z" }, + { url = "https://files.pythonhosted.org/packages/58/f6/0b1b50967c878eae9c84b68dc098ee004cabf57d0d7a449001ddf04b8aaf/pycocoa-25.12.4-py2.py3-none-any.whl", hash = "sha256:189f08be6f3b479bad53711776eec5687a9eddc30e88c1ac24ed458e6053ba1b", size = 227487, upload-time = "2025-12-03T21:11:26.182Z" }, ] [[package]] @@ -3063,17 +3082,17 @@ wheels = [ [[package]] name = "pyside6-essentials" -version = "6.10.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "shiboken6" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/55/bad02ab890c8b8101abef0db4a2e5304be78a69e23a438e4d8555b664467/pyside6_essentials-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:003e871effe1f3e5b876bde715c15a780d876682005a6e989d89f48b8b93e93a", size = 105034090, upload-time = "2025-10-08T09:48:24.944Z" }, - { url = "https://files.pythonhosted.org/packages/5c/75/e17efc7eb900993e0e3925885635c6cf373c817196f09bcbcc102b00ac94/pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1d5e013a8698e37ab8ef360e6960794eb5ef20832a8d562e649b8c5a0574b2d8", size = 76362150, upload-time = "2025-10-08T09:48:31.849Z" }, - { url = "https://files.pythonhosted.org/packages/06/62/fbd1e81caafcda97b147c03f5b06cfaadd8da5fa8298f527d2ec648fa5b7/pyside6_essentials-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b1dd0864f0577a448fb44426b91cafff7ee7cccd1782ba66491e1c668033f998", size = 75454169, upload-time = "2025-10-08T09:48:38.21Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3a/d8211d17e6ca70f641c6ebd309f08ef18930acda60e74082c75875a274da/pyside6_essentials-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:fc167eb211dd1580e20ba90d299e74898e7a5a1306d832421e879641fc03b6fe", size = 74361794, upload-time = "2025-10-08T09:48:44.335Z" }, - { url = "https://files.pythonhosted.org/packages/61/e9/0e22e3c10325c4ff09447fadb43f7962afb82cef0b65358f5704251c6b32/pyside6_essentials-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:6dd0936394cb14da2fd8e869899f5e0925a738b1c8d74c2f22503720ea363fb1", size = 55099467, upload-time = "2025-10-08T09:48:50.902Z" }, + { url = "https://files.pythonhosted.org/packages/04/b0/c43209fecef79912e9b1c70a1b5172b1edf76caebcc885c58c60a09613b0/pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe", size = 105461499, upload-time = "2025-11-20T09:59:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8e/b69ba7fa0c701f3f4136b50460441697ec49ee6ea35c229eb2a5ee4b5952/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856", size = 76764617, upload-time = "2025-11-20T09:59:38.831Z" }, + { url = "https://files.pythonhosted.org/packages/bd/83/569d27f4b6c6b9377150fe1a3745d64d02614021bea233636bc936a23423/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2", size = 75850373, upload-time = "2025-11-20T09:59:56.082Z" }, + { url = "https://files.pythonhosted.org/packages/1e/64/a8df6333de8ccbf3a320e1346ca30d0f314840aff5e3db9b4b66bf38e26c/pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674", size = 74491180, upload-time = "2025-11-20T10:00:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/67/da/65cc6c6a870d4ea908c59b2f0f9e2cf3bfc6c0710ebf278ed72f69865e4e/pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c", size = 55190458, upload-time = "2025-11-20T10:00:26.226Z" }, ] [[package]] @@ -3092,7 +3111,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -3101,9 +3120,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] @@ -3242,41 +3261,41 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/58/6ca66896635352812de66f71cdf9ff86b3a4f79071ca5730088c0cd0fc8d/ruff-0.14.1.tar.gz", hash = "sha256:1dd86253060c4772867c61791588627320abcb6ed1577a90ef432ee319729b69", size = 5513429, upload-time = "2025-10-16T18:05:41.766Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/39/9cc5ab181478d7a18adc1c1e051a84ee02bec94eb9bdfd35643d7c74ca31/ruff-0.14.1-py3-none-linux_armv6l.whl", hash = "sha256:083bfc1f30f4a391ae09c6f4f99d83074416b471775b59288956f5bc18e82f8b", size = 12445415, upload-time = "2025-10-16T18:04:48.227Z" }, - { url = "https://files.pythonhosted.org/packages/ef/2e/1226961855ccd697255988f5a2474890ac7c5863b080b15bd038df820818/ruff-0.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f6fa757cd717f791009f7669fefb09121cc5f7d9bd0ef211371fad68c2b8b224", size = 12784267, upload-time = "2025-10-16T18:04:52.515Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ea/fd9e95863124ed159cd0667ec98449ae461de94acda7101f1acb6066da00/ruff-0.14.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6191903d39ac156921398e9c86b7354d15e3c93772e7dbf26c9fcae59ceccd5", size = 11781872, upload-time = "2025-10-16T18:04:55.396Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5a/e890f7338ff537dba4589a5e02c51baa63020acfb7c8cbbaea4831562c96/ruff-0.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed04f0e04f7a4587244e5c9d7df50e6b5bf2705d75059f409a6421c593a35896", size = 12226558, upload-time = "2025-10-16T18:04:58.166Z" }, - { url = "https://files.pythonhosted.org/packages/a6/7a/8ab5c3377f5bf31e167b73651841217542bcc7aa1c19e83030835cc25204/ruff-0.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c9e6cf6cd4acae0febbce29497accd3632fe2025c0c583c8b87e8dbdeae5f61", size = 12187898, upload-time = "2025-10-16T18:05:01.455Z" }, - { url = "https://files.pythonhosted.org/packages/48/8d/ba7c33aa55406955fc124e62c8259791c3d42e3075a71710fdff9375134f/ruff-0.14.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fa2458527794ecdfbe45f654e42c61f2503a230545a91af839653a0a93dbc6", size = 12939168, upload-time = "2025-10-16T18:05:04.397Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c2/70783f612b50f66d083380e68cbd1696739d88e9b4f6164230375532c637/ruff-0.14.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:39f1c392244e338b21d42ab29b8a6392a722c5090032eb49bb4d6defcdb34345", size = 14386942, upload-time = "2025-10-16T18:05:07.102Z" }, - { url = "https://files.pythonhosted.org/packages/48/44/cd7abb9c776b66d332119d67f96acf15830d120f5b884598a36d9d3f4d83/ruff-0.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7382fa12a26cce1f95070ce450946bec357727aaa428983036362579eadcc5cf", size = 13990622, upload-time = "2025-10-16T18:05:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/eb/56/4259b696db12ac152fe472764b4f78bbdd9b477afd9bc3a6d53c01300b37/ruff-0.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd0bf2be3ae8521e1093a487c4aa3b455882f139787770698530d28ed3fbb37c", size = 13431143, upload-time = "2025-10-16T18:05:13.46Z" }, - { url = "https://files.pythonhosted.org/packages/e0/35/266a80d0eb97bd224b3265b9437bd89dde0dcf4faf299db1212e81824e7e/ruff-0.14.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cabcaa9ccf8089fb4fdb78d17cc0e28241520f50f4c2e88cb6261ed083d85151", size = 13132844, upload-time = "2025-10-16T18:05:16.1Z" }, - { url = "https://files.pythonhosted.org/packages/65/6e/d31ce218acc11a8d91ef208e002a31acf315061a85132f94f3df7a252b18/ruff-0.14.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:747d583400f6125ec11a4c14d1c8474bf75d8b419ad22a111a537ec1a952d192", size = 13401241, upload-time = "2025-10-16T18:05:19.395Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b5/dbc4221bf0b03774b3b2f0d47f39e848d30664157c15b965a14d890637d2/ruff-0.14.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5a6e74c0efd78515a1d13acbfe6c90f0f5bd822aa56b4a6d43a9ffb2ae6e56cd", size = 12132476, upload-time = "2025-10-16T18:05:22.163Z" }, - { url = "https://files.pythonhosted.org/packages/98/4b/ac99194e790ccd092d6a8b5f341f34b6e597d698e3077c032c502d75ea84/ruff-0.14.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0ea6a864d2fb41a4b6d5b456ed164302a0d96f4daac630aeba829abfb059d020", size = 12139749, upload-time = "2025-10-16T18:05:25.162Z" }, - { url = "https://files.pythonhosted.org/packages/47/26/7df917462c3bb5004e6fdfcc505a49e90bcd8a34c54a051953118c00b53a/ruff-0.14.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0826b8764f94229604fa255918d1cc45e583e38c21c203248b0bfc9a0e930be5", size = 12544758, upload-time = "2025-10-16T18:05:28.018Z" }, - { url = "https://files.pythonhosted.org/packages/64/d0/81e7f0648e9764ad9b51dd4be5e5dac3fcfff9602428ccbae288a39c2c22/ruff-0.14.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cbc52160465913a1a3f424c81c62ac8096b6a491468e7d872cb9444a860bc33d", size = 13221811, upload-time = "2025-10-16T18:05:30.707Z" }, - { url = "https://files.pythonhosted.org/packages/c3/07/3c45562c67933cc35f6d5df4ca77dabbcd88fddaca0d6b8371693d29fd56/ruff-0.14.1-py3-none-win32.whl", hash = "sha256:e037ea374aaaff4103240ae79168c0945ae3d5ae8db190603de3b4012bd1def6", size = 12319467, upload-time = "2025-10-16T18:05:33.261Z" }, - { url = "https://files.pythonhosted.org/packages/02/88/0ee4ca507d4aa05f67e292d2e5eb0b3e358fbcfe527554a2eda9ac422d6b/ruff-0.14.1-py3-none-win_amd64.whl", hash = "sha256:59d599cdff9c7f925a017f6f2c256c908b094e55967f93f2821b1439928746a1", size = 13401123, upload-time = "2025-10-16T18:05:35.984Z" }, - { url = "https://files.pythonhosted.org/packages/b8/81/4b6387be7014858d924b843530e1b2a8e531846807516e9bea2ee0936bf7/ruff-0.14.1-py3-none-win_arm64.whl", hash = "sha256:e3b443c4c9f16ae850906b8d0a707b2a4c16f8d2f0a7fe65c475c5886665ce44", size = 12436636, upload-time = "2025-10-16T18:05:38.995Z" }, +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] name = "secretstorage" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/9f/11ef35cf1027c1339552ea7bfe6aaa74a8516d8b5caf6e7d338daf54fd80/secretstorage-3.4.0.tar.gz", hash = "sha256:c46e216d6815aff8a8a18706a2fbfd8d53fcbb0dce99301881687a1b0289ef7c", size = 19748, upload-time = "2025-09-09T16:42:13.859Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/ff/2e2eed29e02c14a5cb6c57f09b2d5b40e65d6cc71f45b52e0be295ccbc2f/secretstorage-3.4.0-py3-none-any.whl", hash = "sha256:0e3b6265c2c63509fb7415717607e4b2c9ab767b7f344a57473b779ca13bd02e", size = 15272, upload-time = "2025-09-09T16:42:12.744Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] @@ -3287,14 +3306,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711 [[package]] name = "shiboken6" -version = "6.10.0" +version = "6.10.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/78/3e730aea82089dd82b1e092bc265778bda329459e6ad9b7134eec5fff3f2/shiboken6-6.10.0-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:7a5f5f400ebfb3a13616030815708289c2154e701a60b9db7833b843e0bee543", size = 476535, upload-time = "2025-10-08T09:49:08Z" }, - { url = "https://files.pythonhosted.org/packages/ea/09/4ffa3284a17b6b765d45b41c9a7f1b2cde6c617c853ac6f170fb62bbbece/shiboken6-6.10.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e612734da515d683696980107cdc0396a3ae0f07b059f0f422ec8a2333810234", size = 271098, upload-time = "2025-10-08T09:49:09.47Z" }, - { url = "https://files.pythonhosted.org/packages/31/29/00e26f33a0fb259c2edce9c761a7a438d7531ca514bdb1a4c072673bd437/shiboken6-6.10.0-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:b01377e68d14132360efb0f4b7233006d26aa8ae9bb50edf00960c2a5f52d148", size = 267698, upload-time = "2025-10-08T09:49:10.694Z" }, - { url = "https://files.pythonhosted.org/packages/11/30/e4624a7e3f0dc9796b701079b77defcce0d32d1afc86bb1d0df04bc3d9e2/shiboken6-6.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:0bc5631c1bf150cbef768a17f5f289aae1cb4db6c6b0c19b2421394e27783717", size = 1234227, upload-time = "2025-10-08T09:49:12.774Z" }, - { url = "https://files.pythonhosted.org/packages/dd/e5/0ab862005ea87dc8647ba958a3099b3b0115fd6491c65da5c5a0f6364db1/shiboken6-6.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:dfc4beab5fec7dbbebbb418f3bf99af865d6953aa0795435563d4cbb82093b61", size = 1794775, upload-time = "2025-10-08T09:49:14.641Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/e5db743d505ceea3efc4cd9634a3bee22a3e2bf6e07cefd28c9b9edabcc6/shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71", size = 478483, upload-time = "2025-11-20T10:08:52.411Z" }, + { url = "https://files.pythonhosted.org/packages/56/ba/b50c1a44b3c4643f482afbf1a0ea58f393827307100389ce29404f9ad3b0/shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4", size = 271993, upload-time = "2025-11-20T10:08:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/16/b8/939c24ebd662b0aa5c945443d0973145b3fb7079f0196274ef7bb4b98f73/shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0", size = 268691, upload-time = "2025-11-20T10:08:55.639Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/8c65ee0fa5e172ebcca03246b1bc3bd96cdaf1d60537316648536b7072a5/shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e", size = 1234704, upload-time = "2025-11-20T10:08:57.417Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6a/c0fea2f2ac7d9d96618c98156500683a4d1f93fea0e8c5a2bc39913d7ef1/shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9", size = 1795567, upload-time = "2025-11-20T10:08:59.184Z" }, ] [[package]] @@ -3326,7 +3345,7 @@ wheels = [ [[package]] name = "trio" -version = "0.31.0" +version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -3336,9 +3355,9 @@ dependencies = [ { name = "sniffio" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/8f/c6e36dd11201e2a565977d8b13f0b027ba4593c1a80bed5185489178e257/trio-0.31.0.tar.gz", hash = "sha256:f71d551ccaa79d0cb73017a33ef3264fde8335728eb4c6391451fe5d253a9d5b", size = 605825, upload-time = "2025-09-09T15:17:15.242Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/ce/0041ddd9160aac0031bcf5ab786c7640d795c797e67c438e15cfedf815c8/trio-0.32.0.tar.gz", hash = "sha256:150f29ec923bcd51231e1d4c71c7006e65247d68759dd1c19af4ea815a25806b", size = 605323, upload-time = "2025-10-31T07:18:17.466Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/5b/94237a3485620dbff9741df02ff6d8acaa5fdec67d81ab3f62e4d8511bf7/trio-0.31.0-py3-none-any.whl", hash = "sha256:b5d14cd6293d79298b49c3485ffd9c07e3ce03a6da8c7dfbe0cb3dd7dc9a4774", size = 512679, upload-time = "2025-09-09T15:17:13.821Z" }, + { url = "https://files.pythonhosted.org/packages/41/bf/945d527ff706233636c73880b22c7c953f3faeb9d6c7e2e85bfbfd0134a0/trio-0.32.0-py3-none-any.whl", hash = "sha256:4ab65984ef8370b79a76659ec87aa3a30c5c7c83ff250b4de88c29a8ab6123c5", size = 512030, upload-time = "2025-10-31T07:18:15.885Z" }, ] [[package]] @@ -3378,11 +3397,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, ] [[package]] From 0edf6d75c99f909b10a90b5a69000b25d486c22e Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 25 Dec 2025 10:02:21 -0600 Subject: [PATCH 79/97] feat: remove patch client filename option from settings window This option may eventually be enitrely removed. It was originally needed for a long irrelevant DDO issue. --- src/onelauncher/settings_window.py | 4 - src/onelauncher/ui/settings_window.ui | 1536 ++++++++++----------- src/onelauncher/ui/settings_window_uic.py | 25 +- 3 files changed, 742 insertions(+), 823 deletions(-) diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 7c9c04b4..67982c98 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -140,7 +140,6 @@ def setup_ui(self) -> None: self.ui.standardLauncherLineEdit.setText( game_config.standard_game_launcher_filename or "" ) - self.ui.patchClientLineEdit.setText(game_config.patch_client_filename) self.ui.standardGameLauncherButton.clicked.connect( lambda: self.nursery.start_soon(self.run_standard_game_launcher) ) @@ -289,8 +288,6 @@ def toggle_advanced_settings(self, is_checked: bool) -> None: self.ui.standardLauncherLineEdit, self.ui.gameSettingsDirLabel, self.ui.gameSettingsDirWidget, - self.ui.patchClientLabel, - self.ui.patchClientLineEdit, ] for widget in advanced_widgets: widget.setVisible(is_checked) @@ -502,7 +499,6 @@ def save_config(self) -> None: ) if self.ui.gameSettingsDirLineEdit.text() else None, - patch_client_filename=self.ui.patchClientLineEdit.text(), ), ) diff --git a/src/onelauncher/ui/settings_window.ui b/src/onelauncher/ui/settings_window.ui index 8aa66e13..560b1052 100644 --- a/src/onelauncher/ui/settings_window.ui +++ b/src/onelauncher/ui/settings_window.ui @@ -1,804 +1,746 @@ - settingsWindow - - - Qt::WindowModality::ApplicationModal - - - - 0 - 0 - 469 - 366 - - - - Settings - - - true - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - 0 - - - - - 20 - - - 15 - - - 20 - - - 20 - - - - - Name - - - gameNameLineEdit - - - - - - - - - - Config ID - - - gameConfigIDLineEdit - - - - - - - true - - - - - - - Description - - - gameDescriptionLineEdit - - - - - - - - - - Newsfeed URL - - - gameNewsfeedLineEdit - - - - - - - - - - Game install directory. There should be a file - called patchclient.dll here - - - Install Directory - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - gameDirLineEdit - - - - - - - - - Game install directory. There should be a - file called patchclient.dll here - - - - - - - Select game install directory from the file - browser - - - ... - - - - - - - - - - 0 - 0 - - - - Browse OneLauncher config/data directory for this - game - - - Browse Config Directory - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - 20 - - - 15 - - - 20 - - - 20 - - - - - - - - Language - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - gameLanguageComboBox - - - - - - - - - - - - - - Enable high resolution game files. You may need to - patch the game after enabling this - - - Hi-Res Graphics - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - highResCheckBox - - - - - - - - 0 - 0 - - - - Enable high resolution game files. You may need to - patch the game after enabling this - - - - - - - - - - Game client version to use. 64-bit is the most - modern. It does work with WINE - - - Client Type - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - clientTypeComboBox - - - - - - - Game client version to use. 64-bit is the most - modern. It does work with WINE - - - - - - - Standard launcher filename - - - Standard Launcher - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - standardLauncherLineEdit - - - - - - - Standard launcher filename - - - - - - - - 0 - 0 - - - - Run Standard Game Launcher - - - QToolButton::ToolButtonPopupMode::MenuButtonPopup - - - - - - - Patch client DLL filename - - - Patch Client DLL - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - patchClientLineEdit - - - - - - - Patch client DLL filename - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - <html><head/><body><p>The - folder where user preferences, screenshots, and addons - are stored. <span style=" - font-weight:700;">Changing this does not move - your existing files. It also won't take affect when - using the official game - launcher.</span></p></body></html> - - - Settings Directory - - - gameSettingsDirLineEdit - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - <html><head/><body><p>The - folder where user preferences, screenshots, - and addons are stored. <span style=" - font-weight:700;">Changing this does - not move your existing files. It also won't - take affect when using the official game - launcher.</span></p></body></html> - - - - - - - Select settings folder from filesystem - - - ... - - - - - - - - - - - - 20 - - - 15 - - - 20 - - - 20 - - - - - Auto Manage Wine - - - autoManageWineCheckBox - - - - - - - - 0 - 0 - - - - - - - - Path to WINE prefix - - - Wine Prefix - - - winePrefixLineEdit - - - - - - - Path to WINE prefix - - - true - - - - - - - Path to WINE executable - - - Wine Executable - - - wineExecutableLineEdit - - - - - - - Path to WINE executable - - - true - - - - - - - Value for the WINEDEBUG environment variable - - - WINEDEBUG - - - wineDebugLineEdit - - - - - - - Value for the WINEDEBUG environment variable - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - QFormLayout::RowWrapPolicy::WrapLongRows - - - 20 - - - 15 - - - 20 - - - 20 - - - - - Default language to use for games - - - Default Language - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - defaultLanguageComboBox - - - - - - - - 0 - 0 - - - - Default language to use for games - - - - - - - Use the default language for OneLauncher even when - the current game is set to a different language - - - Always Use Default Language For UI - - - Qt::AlignmentFlag::AlignCenter - - - true - - - defaultLanguageForUICheckBox - - - - - - - - 0 - 0 - - - - Use the default language for OneLauncher even when - the current game is set to a different language - - - - - - - - - - Games Sorting Mode - - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - gamesSortingModeComboBox - - - - - - - - 0 - 0 - - - - - - - - - 0 - 0 - - - - Manage Games - - - - - - - - 0 - 0 - - - - Run Setup Wizard - - - - - - - Qt::Orientation::Vertical - - - - 20 - 40 - - - - - - - - - - - - 9 - - - 9 - - - 9 - - - 9 - - - - - <html><head/><body><p>Enable - advanced options</p></body></html> - - - Advanced Options - - - true - - - - - - - Qt::Orientation::Horizontal - - - - 40 - 20 - - - - - - - - Qt::Orientation::Horizontal - - - - QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Save - - - - - + settingsWindow + + + Qt::WindowModality::ApplicationModal + + + + 0 + 0 + 469 + 366 + + + + Settings + + + true + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + 0 + + + + + 20 + + + 15 + + + 20 + + + 20 + + + + + Name + + + gameNameLineEdit + + + + + + + + + + Config ID + + + gameConfigIDLineEdit + + + + + + + true + + + + + + + Description + + + gameDescriptionLineEdit + + + + + + + + + + Newsfeed URL + + + gameNewsfeedLineEdit + + + + + + + + + + Game install directory. There should be a file called patchclient.dll here + + + Install Directory + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + gameDirLineEdit + + + + + + + + + Game install directory. There should be a file called patchclient.dll here + + + + + + + Select game install directory from the file browser + + + ... + + + - - - Run with patching disabled + + + + + + 0 + 0 + + + + Browse OneLauncher config/data directory for this game + + + Browse Config Directory + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + 20 + + + 15 + + + 20 + + + 20 + + + + + + + + Language + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + gameLanguageComboBox + + + + + + + + + + + + + + Enable high resolution game files. You may need to patch the game after enabling this + + + Hi-Res Graphics + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + highResCheckBox + + + + + + + + 0 + 0 + + + + Enable high resolution game files. You may need to patch the game after enabling this + + + + + + + + + + Game client version to use. 64-bit is the most modern. It does work with WINE + + + Client Type + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + clientTypeComboBox + + + + + + + Game client version to use. 64-bit is the most modern. It does work with WINE + + + + + + + Standard launcher filename + + + Standard Launcher + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + standardLauncherLineEdit + + + + + + + Standard launcher filename + + + + + + + + 0 + 0 + + + + Run Standard Game Launcher + + + QToolButton::ToolButtonPopupMode::MenuButtonPopup + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + <html><head/><body><p>The folder where user preferences, screenshots, and addons are stored. <span style=" font-weight:700;">Changing this does not move your existing files. It also won't take affect when using the official game launcher.</span></p></body></html> + + + Settings Directory + + + gameSettingsDirLineEdit + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + <html><head/><body><p>The folder where user preferences, screenshots, and addons are stored. <span style=" font-weight:700;">Changing this does not move your existing files. It also won't take affect when using the official game launcher.</span></p></body></html> + + + + - Run launcher using "-skiprawdownload" and - "-disablepatch" arguments + Select settings folder from filesystem + + + ... - + + + + + + + + + + + 20 + + + 15 + + + 20 + + + 20 + + + + + Auto Manage Wine + + + autoManageWineCheckBox + + + + + + + + 0 + 0 + + + + + + + + Path to WINE prefix + + + Wine Prefix + + + winePrefixLineEdit + + + + + + + Path to WINE prefix + + + true + + + + + + + Path to WINE executable + + + Wine Executable + + + wineExecutableLineEdit + + + + + + + Path to WINE executable + + + true + + + + + + + Value for the WINEDEBUG environment variable + + + WINEDEBUG + + + wineDebugLineEdit + + + + + + + Value for the WINEDEBUG environment variable + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + QFormLayout::RowWrapPolicy::WrapLongRows + + + 20 + + + 15 + + + 20 + + + 20 + + + + + Default language to use for games + + + Default Language + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + defaultLanguageComboBox + + + + + + + + 0 + 0 + + + + Default language to use for games + + + + + + + Use the default language for OneLauncher even when the current game is set to a different language + + + Always Use Default Language For UI + + + Qt::AlignmentFlag::AlignCenter + + + true + + + defaultLanguageForUICheckBox + + + + + + + + 0 + 0 + + + + Use the default language for OneLauncher even when the current game is set to a different language + + + + + + + + + + Games Sorting Mode + + + Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + + gamesSortingModeComboBox + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + Manage Games + + + + + + + + 0 + 0 + + + + Run Setup Wizard + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + - - - QDialogWithStylePreview - QDialog -
.qtdesigner.custom_widgets
- 1 -
- - FramelessQDialogWithStylePreview - QDialogWithStylePreview -
.custom_widgets
- 1 -
- - QTabBar - QWidget -
qtabbar.h
- 1 -
- - FixedWordWrapQLabel - QLabel -
.custom_widgets
-
-
- - - - settingsButtonBox - rejected() - settingsWindow - reject() - - - 316 - 260 - - - 286 - 274 - - - - -
\ No newline at end of file +
+ + + + 9 + + + 9 + + + 9 + + + 9 + + + + + Enable advanced options + + + Advanced Options + + + true + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Save + + + + + + + + + Run with patching disabled + + + Run launcher using "-skiprawdownload" and "-disablepatch" arguments + + + + + + QDialogWithStylePreview + QDialog +
.qtdesigner.custom_widgets
+ 1 +
+ + FramelessQDialogWithStylePreview + QDialogWithStylePreview +
.custom_widgets
+ 1 +
+ + QTabBar + QWidget +
qtabbar.h
+ 1 +
+ + FixedWordWrapQLabel + QLabel +
.custom_widgets
+
+
+ + + + settingsButtonBox + rejected() + settingsWindow + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/onelauncher/ui/settings_window_uic.py b/src/onelauncher/ui/settings_window_uic.py index b89dd25f..97c051d4 100644 --- a/src/onelauncher/ui/settings_window_uic.py +++ b/src/onelauncher/ui/settings_window_uic.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- ################################################################################ -## Form generated from reading UI file 'settings.ui' +## Form generated from reading UI file 'settings_window.ui' ## -## Created by: Qt User Interface Compiler version 6.10.0 +## Created by: Qt User Interface Compiler version 6.10.1 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -189,17 +189,6 @@ def setupUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: self.formLayout_2.setWidget(4, QFormLayout.ItemRole.FieldRole, self.standardGameLauncherButton) - self.patchClientLabel = QLabel(self.pageGame) - self.patchClientLabel.setObjectName(u"patchClientLabel") - self.patchClientLabel.setAlignment(Qt.AlignmentFlag.AlignRight|Qt.AlignmentFlag.AlignTrailing|Qt.AlignmentFlag.AlignVCenter) - - self.formLayout_2.setWidget(7, QFormLayout.ItemRole.LabelRole, self.patchClientLabel) - - self.patchClientLineEdit = QLineEdit(self.pageGame) - self.patchClientLineEdit.setObjectName(u"patchClientLineEdit") - - self.formLayout_2.setWidget(7, QFormLayout.ItemRole.FieldRole, self.patchClientLineEdit) - self.verticalSpacer_2 = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) self.formLayout_2.setItem(5, QFormLayout.ItemRole.FieldRole, self.verticalSpacer_2) @@ -383,7 +372,6 @@ def setupUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: self.highResLabel.setBuddy(self.highResCheckBox) self.clientLabel.setBuddy(self.clientTypeComboBox) self.standardLauncherLabel.setBuddy(self.standardLauncherLineEdit) - self.patchClientLabel.setBuddy(self.patchClientLineEdit) self.gameSettingsDirLabel.setBuddy(self.gameSettingsDirLineEdit) self.autoManageWineLabel.setBuddy(self.autoManageWineCheckBox) self.winePrefixLabel.setBuddy(self.winePrefixLineEdit) @@ -458,13 +446,6 @@ def retranslateUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> Non self.standardLauncherLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Standard launcher filename", None)) #endif // QT_CONFIG(tooltip) self.standardGameLauncherButton.setText(QCoreApplication.translate("settingsWindow", u"Run Standard Game Launcher", None)) -#if QT_CONFIG(tooltip) - self.patchClientLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Patch client DLL filename", None)) -#endif // QT_CONFIG(tooltip) - self.patchClientLabel.setText(QCoreApplication.translate("settingsWindow", u"Patch Client DLL", None)) -#if QT_CONFIG(tooltip) - self.patchClientLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Patch client DLL filename", None)) -#endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) self.gameSettingsDirLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"

The folder where user preferences, screenshots, and addons are stored. Changing this does not move your existing files. It also won't take affect when using the official game launcher.

", None)) #endif // QT_CONFIG(tooltip) @@ -517,7 +498,7 @@ def retranslateUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> Non self.gamesManagementButton.setText(QCoreApplication.translate("settingsWindow", u"Manage Games", None)) self.setupWizardButton.setText(QCoreApplication.translate("settingsWindow", u"Run Setup Wizard", None)) #if QT_CONFIG(tooltip) - self.showAdvancedSettingsCheckbox.setToolTip(QCoreApplication.translate("settingsWindow", u"

Enable advanced options

", None)) + self.showAdvancedSettingsCheckbox.setToolTip(QCoreApplication.translate("settingsWindow", u"Enable advanced options", None)) #endif // QT_CONFIG(tooltip) self.showAdvancedSettingsCheckbox.setText(QCoreApplication.translate("settingsWindow", u"Advanced Options", None)) # retranslateUi From b8079d7bc0e5aec2c5f5ac6d6bc74b8eaad9c77b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 25 Dec 2025 16:00:39 -0600 Subject: [PATCH 80/97] fix: handle when there is no system keyring or it fails to unlock --- src/onelauncher/config_manager.py | 83 ++++++++++++++++---------- src/onelauncher/main_window.py | 14 +++++ src/onelauncher/v1x_config_migrator.py | 31 +++++----- 3 files changed, 83 insertions(+), 45 deletions(-) diff --git a/src/onelauncher/config_manager.py b/src/onelauncher/config_manager.py index 5073d200..2c888b65 100644 --- a/src/onelauncher/config_manager.py +++ b/src/onelauncher/config_manager.py @@ -1,4 +1,5 @@ import datetime +import logging from collections.abc import Callable from contextlib import suppress from functools import cache, partial @@ -11,6 +12,7 @@ import keyring import tomlkit from cattrs.preconf.tomlkit import make_converter +from keyring.errors import KeyringLocked, NoKeyringError from packaging.version import InvalidVersion, Version from tomlkit.items import Comment, Table, Whitespace @@ -24,6 +26,8 @@ from .program_config import GamesSortingMode, ProgramConfig from .resources import OneLauncherLocale, available_locales +logger = logging.getLogger(__name__) + PROGRAM_CONFIG_DIR_DEFAULT: Path = platform_dirs.user_config_path PROGRAM_CONFIG_DEFAULT_NAME = f"{__title__.lower()}.toml" GAMES_DIR_DEFAULT: Path = platform_dirs.user_data_path / "games" @@ -738,33 +742,43 @@ def get_game_account_password( self, game_id: GameConfigID, game_account: GameAccountConfig ) -> str | None: """ - Get account password that is saved in keyring. - Will return `None` if no saved passwords are found + Get account password that is saved in keyring. Will return `None` if no saved + passwords are found or there is no keyring backend. """ - return keyring.get_password( - service_name=__title__, - username=self._get_account_keyring_username( - game_id=game_id, game_account=game_account - ), - ) + try: + return keyring.get_password( + service_name=__title__, + username=self._get_account_keyring_username( + game_id=game_id, game_account=game_account + ), + ) + except (NoKeyringError, KeyringLocked): + logger.exception("") + return None def save_game_account_password( self, game_id: GameConfigID, game_account: GameAccountConfig, password: str ) -> None: - """Save account password with keyring""" - keyring.set_password( - service_name=__title__, - username=self._get_account_keyring_username( - game_id=game_id, game_account=game_account - ), - password=password, - ) + """ + Save account password with keyring. Will silently fail if there is no keyring + backend. + """ + with suppress(NoKeyringError, KeyringLocked): + keyring.set_password( + service_name=__title__, + username=self._get_account_keyring_username( + game_id=game_id, game_account=game_account + ), + password=password, + ) def delete_game_account_password( self, game_id: GameConfigID, game_account: GameAccountConfig ) -> None: """Delete account password saved with keyring""" - with suppress(keyring.errors.PasswordDeleteError): + with suppress( + keyring.errors.PasswordDeleteError, NoKeyringError, KeyringLocked + ): keyring.delete_password( service_name=__title__, username=self._get_account_keyring_username( @@ -787,12 +801,16 @@ def get_game_account_last_used_subscription_name( Get name of the subscription that was last played with from keyring. See `login_account.py` """ - return keyring.get_password( - service_name=__title__, - username=self._get_account_last_used_subscription_keyring_username( - game_id=game_id, game_account=game_account - ), - ) + try: + return keyring.get_password( + service_name=__title__, + username=self._get_account_last_used_subscription_keyring_username( + game_id=game_id, game_account=game_account + ), + ) + except (NoKeyringError, KeyringLocked): + logger.exception("") + return None def save_game_account_last_used_subscription_name( self, @@ -801,13 +819,14 @@ def save_game_account_last_used_subscription_name( subscription_name: str, ) -> None: """Save last used subscription name with keyring""" - keyring.set_password( - service_name=__title__, - username=self._get_account_last_used_subscription_keyring_username( - game_id=game_id, game_account=game_account - ), - password=subscription_name, - ) + with suppress(NoKeyringError, KeyringLocked): + keyring.set_password( + service_name=__title__, + username=self._get_account_last_used_subscription_keyring_username( + game_id=game_id, game_account=game_account + ), + password=subscription_name, + ) def delete_game_account_last_used_subscription_name( self, @@ -815,7 +834,9 @@ def delete_game_account_last_used_subscription_name( game_account: GameAccountConfig, ) -> None: """Delete last used subscription name saved with keyring""" - with suppress(keyring.errors.PasswordDeleteError): + with suppress( + keyring.errors.PasswordDeleteError, NoKeyringError, KeyringLocked + ): keyring.delete_password( service_name=__title__, username=self._get_account_last_used_subscription_keyring_username( diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index f7adc14b..c4bc8d78 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -35,9 +35,11 @@ import attrs import httpx +import keyring import packaging.version import qtawesome import trio +from keyring.errors import KeyringLocked, NoKeyringError from PySide6 import QtCore, QtGui, QtWidgets from typing_extensions import override from xmlschema import XMLSchemaValidationError @@ -888,6 +890,18 @@ async def InitialSetup(self) -> None: await self.InitialSetup() return + try: + keyring.get_password(__about__.__title__, "TEST") + except NoKeyringError: + logger.warning( + "No system keyring found. Password and subscription saving will fail.", + exc_info=True, + ) + except KeyringLocked: + logger.exception( + "Failed to unlock system keyring. Password and subscription saving will fail." + ) + self.loadAllSavedAccounts() self.ui.cboAccount.setEnabled(True) self.ui.txtPassword.setEnabled(True) diff --git a/src/onelauncher/v1x_config_migrator.py b/src/onelauncher/v1x_config_migrator.py index f22c4ddc..1992eec5 100644 --- a/src/onelauncher/v1x_config_migrator.py +++ b/src/onelauncher/v1x_config_migrator.py @@ -9,6 +9,7 @@ import cattrs import keyring import xmlschema +from keyring.errors import KeyringLocked, NoKeyringError from onelauncher.addons.config import AddonsConfigSection from onelauncher.addons.startup_script import StartupScript @@ -338,19 +339,21 @@ def migrate_v1x_config(config_manager: ConfigManager, delete_old_config: bool) - game_id, accounts=account_configs ) # Add account passwords to Keyring - service_name = f"OneLauncher{'LOTRO' if game_config.game_type == GameType.LOTRO else 'DDO'}" - for account_config in account_configs: - if password := keyring.get_password( - service_name=service_name, - username=account_config.username, - ): - config_manager.save_game_account_password( - game_id=game_id, game_account=account_config, password=password - ) - if delete_old_config: - with suppress(keyring.errors.PasswordDeleteError): - keyring.delete_password( - service_name=service_name, username=account_config.username - ) + with suppress(NoKeyringError, KeyringLocked): + service_name = f"OneLauncher{'LOTRO' if game_config.game_type == GameType.LOTRO else 'DDO'}" + for account_config in account_configs: + if password := keyring.get_password( + service_name=service_name, + username=account_config.username, + ): + config_manager.save_game_account_password( + game_id=game_id, game_account=account_config, password=password + ) + if delete_old_config: + with suppress(keyring.errors.PasswordDeleteError): + keyring.delete_password( + service_name=service_name, + username=account_config.username, + ) if delete_old_config: shutil.rmtree(config_dir) From 2bc1ca19cadcd6bd677af605c9f0dde63c02b96f Mon Sep 17 00:00:00 2001 From: June Stepp Date: Thu, 25 Dec 2025 18:53:26 -0600 Subject: [PATCH 81/97] feat(settings_window): use icons for select from file browser buttons Same icon as the install game window. --- src/onelauncher/settings_window.py | 27 ++++++++++++++++++----- src/onelauncher/ui/settings_window.ui | 23 +++++++++++++------ src/onelauncher/ui/settings_window_uic.py | 24 +++++++++++--------- 3 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index 67982c98..b0bcda40 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -35,6 +35,7 @@ from types import MappingProxyType import attrs +import qtawesome import trio from PySide6 import QtCore, QtGui, QtWidgets from typing_extensions import override @@ -79,10 +80,12 @@ def __init__(self, config_manager: ConfigManager, game_id: GameConfigID): self.titleBar.hide() self.config_manager = config_manager self.game_id = game_id + + def setup_ui(self) -> None: self.ui = Ui_settingsWindow() self.ui.setupUi(self) + color_scheme_changed = get_qapp().styleHints().colorSchemeChanged - def setup_ui(self) -> None: self.tab_names = list(TabName) if os.name == "nt": self.tab_names.remove(TabName.WINE) @@ -188,7 +191,12 @@ def setup_ui(self) -> None: partial(self.start_setup_wizard, games_managing=True) ) ) - self.ui.gameDirButton.clicked.connect(self.choose_game_dir) + get_browse_dir_icon = partial(qtawesome.icon, "mdi6.folder-open-outline") + self.ui.browseForGameDirButton.setIcon(get_browse_dir_icon()) + color_scheme_changed.connect( + lambda: self.ui.browseForGameDirButton.setIcon(get_browse_dir_icon()) + ) + self.ui.browseForGameDirButton.clicked.connect(self.browse_for_game_install_dir) self.ui.showAdvancedSettingsCheckbox.clicked.connect( self.toggle_advanced_settings ) @@ -249,7 +257,16 @@ async def setup_newsfeed_option(self) -> None: self.ui.gameNewsfeedLineEdit.setText(game_config.newsfeed or "") async def setup_game_settings_dir_option(self) -> None: - self.ui.gameSettingsDirButton.clicked.connect(self.choose_game_settings_dir) + get_browse_dir_icon = partial(qtawesome.icon, "mdi6.folder-open-outline") + self.ui.browseForGameSettingsDirButton.setIcon(get_browse_dir_icon()) + get_qapp().styleHints().colorSchemeChanged.connect( + lambda: self.ui.browseForGameSettingsDirButton.setIcon( + get_browse_dir_icon() + ) + ) + self.ui.browseForGameSettingsDirButton.clicked.connect( + self.browse_for_game_settings_dir + ) game_config = self.config_manager.read_game_config_file(self.game_id) self.ui.gameSettingsDirLineEdit.setText( @@ -379,7 +396,7 @@ def browse_for_directory( folder = CaseInsensitiveAbsolutePath(filename) return folder - def choose_game_dir(self) -> None: + def browse_for_game_install_dir(self) -> None: gameDirLineEdit = self.ui.gameDirLineEdit.text() if gameDirLineEdit == "": @@ -406,7 +423,7 @@ def choose_game_dir(self) -> None: self, ) - def choose_game_settings_dir(self) -> None: + def browse_for_game_settings_dir(self) -> None: folder = self.browse_for_directory( start_dir=platform_dirs.user_documents_path, caption="Game Settings Directory", diff --git a/src/onelauncher/ui/settings_window.ui b/src/onelauncher/ui/settings_window.ui index 560b1052..a665b6b9 100644 --- a/src/onelauncher/ui/settings_window.ui +++ b/src/onelauncher/ui/settings_window.ui @@ -139,12 +139,14 @@ - + Select game install directory from the file browser - - ... + + + icon-base + @@ -361,12 +363,14 @@ - + - Select settings folder from filesystem + Select game settings directory from the file browser - - ... + + + icon-base + @@ -723,6 +727,11 @@ QLabel
.custom_widgets
+ + NoOddSizesQToolButton + QToolButton +
.custom_widgets
+
diff --git a/src/onelauncher/ui/settings_window_uic.py b/src/onelauncher/ui/settings_window_uic.py index 97c051d4..dc70ffd8 100644 --- a/src/onelauncher/ui/settings_window_uic.py +++ b/src/onelauncher/ui/settings_window_uic.py @@ -22,7 +22,7 @@ QStackedWidget, QTabBar, QToolButton, QVBoxLayout, QWidget) -from .custom_widgets import (FixedWordWrapQLabel, FramelessQDialogWithStylePreview) +from .custom_widgets import (FixedWordWrapQLabel, FramelessQDialogWithStylePreview, NoOddSizesQToolButton) from .qtdesigner.custom_widgets import QDialogWithStylePreview class Ui_settingsWindow(object): @@ -104,10 +104,10 @@ def setupUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: self.gameDirLayout.addWidget(self.gameDirLineEdit) - self.gameDirButton = QToolButton(self.pageGameInfo) - self.gameDirButton.setObjectName(u"gameDirButton") + self.browseForGameDirButton = NoOddSizesQToolButton(self.pageGameInfo) + self.browseForGameDirButton.setObjectName(u"browseForGameDirButton") - self.gameDirLayout.addWidget(self.gameDirButton) + self.gameDirLayout.addWidget(self.browseForGameDirButton) self.formLayout.setLayout(4, QFormLayout.ItemRole.FieldRole, self.gameDirLayout) @@ -208,10 +208,10 @@ def setupUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: self.gameSettingsDirLayout.addWidget(self.gameSettingsDirLineEdit) - self.gameSettingsDirButton = QToolButton(self.gameSettingsDirWidget) - self.gameSettingsDirButton.setObjectName(u"gameSettingsDirButton") + self.browseForGameSettingsDirButton = NoOddSizesQToolButton(self.gameSettingsDirWidget) + self.browseForGameSettingsDirButton.setObjectName(u"browseForGameSettingsDirButton") - self.gameSettingsDirLayout.addWidget(self.gameSettingsDirButton) + self.gameSettingsDirLayout.addWidget(self.browseForGameSettingsDirButton) self.formLayout_2.setWidget(6, QFormLayout.ItemRole.FieldRole, self.gameSettingsDirWidget) @@ -409,9 +409,10 @@ def retranslateUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> Non self.gameDirLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Game install directory. There should be a file called patchclient.dll here", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select game install directory from the file browser", None)) + self.browseForGameDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select game install directory from the file browser", None)) #endif // QT_CONFIG(tooltip) - self.gameDirButton.setText(QCoreApplication.translate("settingsWindow", u"...", None)) + self.browseForGameDirButton.setProperty(u"qssClass", [ + QCoreApplication.translate("settingsWindow", u"icon-base", None)]) #if QT_CONFIG(tooltip) self.browseGameConfigDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Browse OneLauncher config/data directory for this game", None)) #endif // QT_CONFIG(tooltip) @@ -454,9 +455,10 @@ def retranslateUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> Non self.gameSettingsDirLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"

The folder where user preferences, screenshots, and addons are stored. Changing this does not move your existing files. It also won't take affect when using the official game launcher.

", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.gameSettingsDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select settings folder from filesystem", None)) + self.browseForGameSettingsDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select game settings directory from the file browser", None)) #endif // QT_CONFIG(tooltip) - self.gameSettingsDirButton.setText(QCoreApplication.translate("settingsWindow", u"...", None)) + self.browseForGameSettingsDirButton.setProperty(u"qssClass", [ + QCoreApplication.translate("settingsWindow", u"icon-base", None)]) self.autoManageWineLabel.setText(QCoreApplication.translate("settingsWindow", u"Auto Manage Wine", None)) #if QT_CONFIG(tooltip) self.winePrefixLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE prefix", None)) From d5100983bee678602e6d19c9ef1c09231f1906af Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 26 Dec 2025 10:59:49 -0600 Subject: [PATCH 82/97] fix(login_account): handle username or password too short errors --- src/onelauncher/main_window.py | 4 ++-- src/onelauncher/network/login_account.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index c4bc8d78..5b101c0c 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -579,8 +579,8 @@ async def authenticate_account( ) or "", ) - except login_account.WrongUsernameOrPasswordError: - logger.exception("Username or password is incorrect") + except login_account.WrongUsernameOrPasswordError as e: + logger.exception(e.msg) return None except httpx.HTTPError: logger.exception("Network error while authenticating account") diff --git a/src/onelauncher/network/login_account.py b/src/onelauncher/network/login_account.py index a1399ae9..59aace42 100644 --- a/src/onelauncher/network/login_account.py +++ b/src/onelauncher/network/login_account.py @@ -1,5 +1,6 @@ from typing import Any, NamedTuple, Self +import attrs import zeep.exceptions from .soap import GLSServiceError, get_soap_client @@ -108,9 +109,12 @@ def from_soap_response_dict( raise GLSServiceError("LoginAccount response missing required value") from e +@attrs.frozen(kw_only=True) class WrongUsernameOrPasswordError(Exception): """Either the username does not exist, or the password was incorrect.""" + msg: str + async def login_account( auth_server: str, username: str, password: str @@ -139,8 +143,14 @@ async def login_account( await client.service.LoginAccount(username, password, "") ) except zeep.exceptions.Fault as e: - if e.message == "No Subscriber Formal Entity was found.": - raise WrongUsernameOrPasswordError("") from e + if "no subscriber formal entity was found" in e.message.lower(): + raise WrongUsernameOrPasswordError( + msg="Username or password is incorrect" + ) from e + elif "user name is too short" in e.message.lower(): + raise WrongUsernameOrPasswordError(msg="Username is too short") from e + elif "password is too short" in e.message.lower(): + raise WrongUsernameOrPasswordError(msg="Password is too short") from e else: raise GLSServiceError("") from e except zeep.exceptions.Error as e: From a1b02090fd045e1da0cf5573dac49d65fa3b07fe Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 26 Dec 2025 14:25:05 -0600 Subject: [PATCH 83/97] feat: add message to preview newsfeeds linking to where latest info is --- src/onelauncher/main_window.py | 13 +++---- src/onelauncher/network/game_newsfeed.py | 44 +++++++++++++++++++++--- src/onelauncher/official_clients.py | 9 +++-- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 5b101c0c..5bb588c4 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -65,7 +65,7 @@ GameLauncherConfig, GameLauncherConfigParseError, ) -from .network.game_newsfeed import newsfeed_url_to_html +from .network.game_newsfeed import get_game_newsfeed_html from .network.game_services_info import GameServicesInfo from .network.httpx_client import get_httpx_client from .network.soap import GLSServiceError @@ -990,13 +990,14 @@ async def get_game_launcher_config( async def load_newsfeed(self, game_launcher_config: GameLauncherConfig) -> None: ui_locale = self.config_manager.get_ui_locale(self.game_id) - newsfeed_url = self.config_manager.get_game_config( - self.game_id - ).newsfeed or game_launcher_config.get_newfeed_url(ui_locale) + game_config = self.config_manager.get_game_config(self.game_id) + newsfeed_url = game_config.newsfeed or game_launcher_config.get_newfeed_url( + ui_locale + ) try: self.ui.txtFeed.setHtml( - await newsfeed_url_to_html( - url=newsfeed_url, babel_locale=ui_locale.babel_locale + await get_game_newsfeed_html( + url=newsfeed_url, locale=ui_locale, game_config=game_config ) ) except httpx.HTTPError: diff --git a/src/onelauncher/network/game_newsfeed.py b/src/onelauncher/network/game_newsfeed.py index e9faa202..a282826e 100644 --- a/src/onelauncher/network/game_newsfeed.py +++ b/src/onelauncher/network/game_newsfeed.py @@ -3,12 +3,19 @@ import logging from datetime import datetime from io import StringIO +from typing import assert_never import feedparser -from babel import Locale from babel.dates import format_datetime from PySide6 import QtCore +from onelauncher.game_config import GameConfig, GameType +from onelauncher.official_clients import ( + DDO_PREVIEW_LATEST_INFO_URL, + LOTRO_PREVIEW_LATEST_INFO_URL, + is_official_game_server, +) +from onelauncher.resources import OneLauncherLocale from onelauncher.ui.qtapp import get_qapp from .httpx_client import get_httpx_client @@ -16,7 +23,9 @@ logger = logging.getLogger(__name__) -async def newsfeed_url_to_html(url: str, babel_locale: Locale) -> str: +async def get_game_newsfeed_html( + url: str, locale: OneLauncherLocale, game_config: GameConfig +) -> str: """ Raises: HTTPError: Network error while downloading newsfeed @@ -24,7 +33,12 @@ async def newsfeed_url_to_html(url: str, babel_locale: Locale) -> str: response = await get_httpx_client(url).get(url) response.raise_for_status() - return newsfeed_xml_to_html(response.text, babel_locale, url) + return newsfeed_xml_to_html( + newsfeed_string=response.text, + locale=locale, + game_config=game_config, + original_feed_url=url, + ) def _escape_feed_val(details: feedparser.util.FeedParserDict) -> str: # type: ignore[no-any-unimported] @@ -70,7 +84,10 @@ def get_newsfeed_css() -> str: def newsfeed_xml_to_html( - newsfeed_string: str, babel_locale: Locale, original_feed_url: str | None = None + newsfeed_string: str, + locale: OneLauncherLocale, + game_config: GameConfig, + original_feed_url: str, ) -> str: with StringIO(initial_value=newsfeed_string) as feed_text_stream: feed_dict = feedparser.parse(feed_text_stream.getvalue()) @@ -89,7 +106,7 @@ def newsfeed_xml_to_html( timestamp = calendar.timegm(entry["published_parsed"]) datetime_object = datetime.fromtimestamp(timestamp) date = format_datetime( - datetime_object, format="medium", locale=babel_locale + datetime_object, format="medium", locale=locale.babel_locale ) else: date = "" @@ -117,11 +134,28 @@ def newsfeed_xml_to_html(
""" + if game_config.game_type == GameType.LOTRO: + preview_server_forums_url = LOTRO_PREVIEW_LATEST_INFO_URL + elif game_config.game_type == GameType.DDO: + preview_server_forums_url = DDO_PREVIEW_LATEST_INFO_URL + else: + assert_never() + preview_server_status_message = f""" +
+

+ Go to the forums for the + latest info. This feed can be out of date. +

+
+
+ """ + feed_url = feed_dict.feed.get("link") or original_feed_url return f"""
+ {preview_server_status_message if game_config.is_preview_client and is_official_game_server(original_feed_url) else ""} {entries_html}
{"..." if feed_url else ""} diff --git a/src/onelauncher/official_clients.py b/src/onelauncher/official_clients.py index 4b0b434b..07cf1faa 100644 --- a/src/onelauncher/official_clients.py +++ b/src/onelauncher/official_clients.py @@ -57,7 +57,9 @@ DDO_GLS_PREVIEW_DOMAIN, ) -# Forums where RSS feeds used as newsfeeds are +LOTRO_DOMAIN = "www.lotro.com" +DDO_DOMAIN = "www.ddo.com" +# Forums where some RSS feeds used as newsfeeds are LOTRO_FORMS_DOMAINS: Final = ( "forums.lotro.com", "forums-old.lotro.com", @@ -72,6 +74,9 @@ "http://www.ddo.com/index.php?option=com_bca-rss-syndicator&feed_id=3" ) DDO_PREVIEW_NEWS_URL_TEMPLATE: Final = "https://forums.ddo.com/index.php?forums/lamannia-news-and-official-discussions.20/index.rss" +# Where users should look to get the current status of the preview versions of each game. +LOTRO_PREVIEW_LATEST_INFO_URL: Final = "https://forums.lotro.com/index.php?forums/bullroarer-official-discussions-and-information.37/&order=post_date&direction=desc" +DDO_PREVIEW_LATEST_INFO_URL: Final = "https://forums.ddo.com/index.php?forums/lamannia-news-and-official-discussions.20/&order=post_date&direction=desc" # There may be specific better ciphers that can be used instead of just @@ -90,7 +95,7 @@ def is_official_game_server(url: str) -> bool: + LOTRO_FORMS_DOMAINS + DDO_GLS_DOMAINS + DDO_FORMS_DOMAINS - + (LOTRO_GLS_INVALID_SSL_DOMAIN, DDO_GLS_PREVIEW_IP) + + (LOTRO_DOMAIN, DDO_DOMAIN, LOTRO_GLS_INVALID_SSL_DOMAIN, DDO_GLS_PREVIEW_IP) ) From 10a0418a5bf568035a9662647ce5b0589ffd651a Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 26 Dec 2025 14:51:28 -0600 Subject: [PATCH 84/97] fix: remove custom official servers ciphers config The official games servers finally work with the standard cipher set! --- src/onelauncher/official_clients.py | 25 ++----------------------- 1 file changed, 2 insertions(+), 23 deletions(-) diff --git a/src/onelauncher/official_clients.py b/src/onelauncher/official_clients.py index 07cf1faa..21c46fbb 100644 --- a/src/onelauncher/official_clients.py +++ b/src/onelauncher/official_clients.py @@ -27,7 +27,6 @@ ########################################################################### import logging import socket -import ssl from functools import cache from pathlib import Path from typing import Final, assert_never @@ -79,10 +78,6 @@ DDO_PREVIEW_LATEST_INFO_URL: Final = "https://forums.ddo.com/index.php?forums/lamannia-news-and-official-discussions.20/&order=post_date&direction=desc" -# There may be specific better ciphers that can be used instead of just -# lowering the security level. I'm not knowledgable on this topic though. -OFFICIAL_CLIENT_CIPHERS: Final = "DEFAULT@SECLEVEL=1" - CONNECTION_RETRIES: Final[int] = 3 TIMEOUT: Final = httpx.Timeout(timeout=6.0, read=10.0) @@ -148,25 +143,12 @@ async def _httpx_request_hook(request: httpx.Request) -> None: _httpx_request_hook_sync(request) -def get_official_servers_ssl_context() -> ssl.SSLContext: - """ - Return SSLContext configured for the lower security of the official servers - """ - ssl_context = httpx.create_ssl_context() - ssl_context.verify_mode = ssl.CERT_REQUIRED - ssl_context.set_ciphers(OFFICIAL_CLIENT_CIPHERS) - return ssl_context - - @cache def get_official_servers_httpx_client() -> httpx.AsyncClient: """Return httpx client configured to work with official game servers""" - transport = httpx.AsyncHTTPTransport( - verify=get_official_servers_ssl_context(), retries=CONNECTION_RETRIES - ) + transport = httpx.AsyncHTTPTransport(retries=CONNECTION_RETRIES) return httpx.AsyncClient( timeout=TIMEOUT, - verify=get_official_servers_ssl_context(), event_hooks={"request": [_httpx_request_hook]}, transport=transport, ) @@ -175,12 +157,9 @@ def get_official_servers_httpx_client() -> httpx.AsyncClient: @cache def get_official_servers_httpx_client_sync() -> httpx.Client: """Return httpx client configured to work with official game servers""" - transport = httpx.HTTPTransport( - verify=get_official_servers_ssl_context(), retries=CONNECTION_RETRIES - ) + transport = httpx.HTTPTransport(retries=CONNECTION_RETRIES) return httpx.Client( timeout=TIMEOUT, - verify=get_official_servers_ssl_context(), event_hooks={"request": [_httpx_request_hook_sync]}, transport=transport, ) From f4f64f48a6fc164e7a05d0433d147ca3f1947d64 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 26 Dec 2025 15:33:59 -0600 Subject: [PATCH 85/97] fix(world_login_queue): better handle HRESULT 0x80004005 --- src/onelauncher/main_window.py | 12 +++++----- src/onelauncher/network/world_login_queue.py | 24 ++++++++++++++++---- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 5bb588c4..d99f350e 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -698,13 +698,13 @@ async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: # game_launcher_config=game_launcher_config, ) except httpx.HTTPError: - logger.exception("Network error while joining world queue") + logger.exception("Network error while joining world login queue") return - except (JoinWorldQueueFailedError, WorldQueueResultXMLParseError): - logger.exception( - "Non-network error joining world queue. " - "Please report this error if it continues" - ) + except WorldQueueResultXMLParseError: + logger.exception("Error parsing world login queue response") + return + except JoinWorldQueueFailedError as e: + logger.exception(e.msg) return self.run_startup_scripts() diff --git a/src/onelauncher/network/world_login_queue.py b/src/onelauncher/network/world_login_queue.py index bb9dd34c..56201f89 100644 --- a/src/onelauncher/network/world_login_queue.py +++ b/src/onelauncher/network/world_login_queue.py @@ -1,5 +1,6 @@ from typing import Any, Final, NamedTuple +import attrs import xmlschema from ..resources import data_dir @@ -15,8 +16,9 @@ class WorldQueueResultXMLParseError(Exception): """Error with content/formatting of world queue response XML""" +@attrs.frozen(kw_only=True) class JoinWorldQueueFailedError(Exception): - """Failed to join world login queue""" + msg: str class WorldLoginQueue: @@ -84,9 +86,23 @@ async def join_queue(self) -> JoinWorldQueueResult: # Check if joining queue failed. See # https://en.wikipedia.org/wiki/HRESULT if hresult >> 31 & 1: - raise JoinWorldQueueFailedError( - f"Joining world login queue failed with HRESULT: {hex(hresult)}" - ) + # This HRESULT is commonly known as "Unspecified failure". For LOTRO/DDO, + # I've so far seen it when: + # - The preview servers are closed. Looking at the world status in this + # case, the only allowed billing role is "TurbineEmployee". + # - Once, when the servers were down. + # - After probably logging in too many times and getting the account + # timed out/suspended for a little while. + if hresult == 0x80004005: # noqa: PLR2004 + raise JoinWorldQueueFailedError( + msg="Failed to join world login queue. Please try again later." + ) + else: + exception = JoinWorldQueueFailedError( + msg="Non-network error joining world login queue" + ) + exception.add_note(f"HRESULT: {hex(hresult)}") + raise exception try: return JoinWorldQueueResult( From 34da1532504e4b7a8f035af82597b26a7d6c9b6c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Fri, 26 Dec 2025 16:21:19 -0600 Subject: [PATCH 86/97] feat: compare product tokens with world allowed/denied billing roles To see if the user is allowed to join the world. --- src/onelauncher/main_window.py | 18 +++++ src/onelauncher/network/game_services_info.py | 8 +-- src/onelauncher/network/login_account.py | 67 +++++++++---------- src/onelauncher/network/world.py | 67 ++++++++----------- 4 files changed, 83 insertions(+), 77 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index d99f350e..2587ee5e 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -689,6 +689,24 @@ async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: # ) return + # Check if the user is allowed to join this world. + if subscription.product_tokens is not None and ( + ( + selected_world_status.allowed_billing_roles is not None + and not selected_world_status.allowed_billing_roles.intersection( + subscription.product_tokens + ) + ) + or ( + selected_world_status.denied_billing_roles + and selected_world_status.denied_billing_roles.intersection( + subscription.product_tokens + ) + ) + ): + logger.exception("You are not allowed to join this world") + return + if selected_world_status.queue_url: try: await self.world_queue( diff --git a/src/onelauncher/network/game_services_info.py b/src/onelauncher/network/game_services_info.py index a1d89694..9f802a90 100644 --- a/src/onelauncher/network/game_services_info.py +++ b/src/onelauncher/network/game_services_info.py @@ -111,10 +111,10 @@ def _get_worlds( world_dicts = datacenter_dict["Worlds"]["World"] return { World( - world_dict["Name"], - world_dict["ChatServerUrl"], - world_dict["StatusServerUrl"], - gls_datacenter_service, + name=world_dict["Name"], + chat_server_url=world_dict["ChatServerUrl"], + status_server_url=world_dict["StatusServerUrl"], + gls_datacenter_service=gls_datacenter_service, ) for world_dict in world_dicts } diff --git a/src/onelauncher/network/login_account.py b/src/onelauncher/network/login_account.py index 59aace42..b9b2a39b 100644 --- a/src/onelauncher/network/login_account.py +++ b/src/onelauncher/network/login_account.py @@ -1,4 +1,4 @@ -from typing import Any, NamedTuple, Self +from typing import Any, Self import attrs import zeep.exceptions @@ -6,11 +6,12 @@ from .soap import GLSServiceError, get_soap_client -class GameSubscription(NamedTuple): +@attrs.frozen(kw_only=True) +class GameSubscription: datacenter_game_name: str name: str description: str - product_tokens: list[str] | None + product_tokens: set[str] | None customer_service_tokens: list[str] | None expiration_date: str | None status: str | None @@ -28,12 +29,13 @@ def from_dict(cls: type[Self], subscription_dict: dict[str, Any]) -> Self: See `login_account`. """ try: - product_tokens: list[str] = [] if ( "ProductTokens" in subscription_dict and subscription_dict["ProductTokens"] is not None ): - product_tokens = subscription_dict["ProductTokens"]["string"] + product_tokens = set(subscription_dict["ProductTokens"]["string"]) + else: + product_tokens = None customer_service_tokens: list[str] = [] if ( @@ -45,38 +47,34 @@ def from_dict(cls: type[Self], subscription_dict: dict[str, Any]) -> Self: ] return cls( - subscription_dict["Game"], - subscription_dict["Name"], - subscription_dict["Description"], - product_tokens or None, - customer_service_tokens or None, - subscription_dict["ExpirationDate"], - subscription_dict["Status"], - subscription_dict["NextBillingDate"], - subscription_dict["PendingCancelDate"], - subscription_dict["AutoRenew"], - subscription_dict["BillingSystemTime"], - subscription_dict["AdditionalInfo"], + datacenter_game_name=subscription_dict["Game"], + name=subscription_dict["Name"], + description=subscription_dict["Description"], + product_tokens=product_tokens, + customer_service_tokens=customer_service_tokens or None, + expiration_date=subscription_dict["ExpirationDate"], + status=subscription_dict["Status"], + next_billing_date=subscription_dict["NextBillingDate"], + pending_cancel_date=subscription_dict["PendingCancelDate"], + auto_renew=subscription_dict["AutoRenew"], + billing_system_time=subscription_dict["BillingSystemTime"], + additional_info=subscription_dict["AdditionalInfo"], ) except KeyError as e: raise GLSServiceError("LoginAccount response missing required value") from e +@attrs.frozen(kw_only=True) class AccountLoginResponse: - def __init__( - self, subscriptions: list[GameSubscription], session_ticket: str - ) -> None: - self._subscriptions = subscriptions - self._session_ticket = session_ticket - - @property - def subscriptions(self) -> list[GameSubscription]: - """All subscriptions in the account. Not all of these are used - for logging into the game. There can also be subscriptions for - multiple game types on a single account. + subscriptions: list[GameSubscription] + """ + All subscriptions in the account. Not all of these are used + for logging into the game. There can also be subscriptions for + multiple game types on a single account. - Using `get_game_subscriptions` is recommended for most use cases.""" - return self._subscriptions + Using `get_game_subscriptions` is recommended for most use cases. + """ + session_ticket: str def get_game_subscriptions( self, datacenter_game_name: str @@ -87,10 +85,6 @@ def get_game_subscriptions( if subscription.datacenter_game_name == datacenter_game_name ] - @property - def session_ticket(self) -> str: - return self._session_ticket - @classmethod def from_soap_response_dict( cls: type[Self], login_response_dict: dict[str, Any] @@ -104,7 +98,10 @@ def from_soap_response_dict( for sub_dict in login_response_dict["Subscriptions"]["GameSubscription"] ] - return cls(subscriptions, login_response_dict["Ticket"]) + return cls( + subscriptions=subscriptions, + session_ticket=login_response_dict["Ticket"], + ) except KeyError as e: raise GLSServiceError("LoginAccount response missing required value") from e diff --git a/src/onelauncher/network/world.py b/src/onelauncher/network/world.py index 1cef20ab..961fabd3 100644 --- a/src/onelauncher/network/world.py +++ b/src/onelauncher/network/world.py @@ -1,7 +1,8 @@ import logging -from typing import Any, Final +from typing import Any, ClassVar from urllib.parse import urlparse, urlunparse +import attrs import httpx import xmlschema from asyncache import cached @@ -18,49 +19,25 @@ class WorldUnavailableError(Exception): """World is unavailable.""" +@attrs.frozen(kw_only=True) class WorldStatus: - def __init__(self, queue_url: str, login_server: str) -> None: - self._queue_url = queue_url - self._login_server = login_server - - @property - def queue_url(self) -> str: - return self._queue_url - - @property - def login_server(self) -> str: - return self._login_server + queue_url: str + login_server: str + allowed_billing_roles: set[str] | None + denied_billing_roles: set[str] | None +@attrs.frozen(kw_only=True) class World: - _WORLD_STATUS_SCHEMA: Final = xmlschema.XMLSchema( + name: str + chat_server_url: str + status_server_url: str + _gls_datacenter_service: str | None = None + + _WORLD_STATUS_SCHEMA: ClassVar = xmlschema.XMLSchema( data_dir / "network" / "schemas" / "world_status.xsd" ) - def __init__( - self, - name: str, - chat_server_url: str, - status_server_url: str, - gls_datacenter_service: str | None = None, - ): - self._name = name - self._chat_server_url = chat_server_url - self._status_server_url = status_server_url - self._gls_datacenter_service = gls_datacenter_service - - @property - def name(self) -> str: - return self._name - - @property - def chat_server_url(self) -> str: - return self._chat_server_url - - @property - def status_server_url(self) -> str: - return self._status_server_url - @cached(cache=TTLCache(maxsize=1, ttl=60)) async def get_status(self) -> WorldStatus: """Return current world status info @@ -86,7 +63,21 @@ async def get_status(self) -> WorldStatus: server for server in status_dict["loginservers"].split(";") if server ) - return WorldStatus(queue_urls[0], login_servers[0]) + if roles_str := status_dict.get("allow_billing_role"): + allowed_billing_roles = set(roles_str.split(",")) + else: + allowed_billing_roles = None + if roles_str := status_dict.get("deny_billing_role"): + denied_billing_roles = set(roles_str.split(",")) + else: + denied_billing_roles = None + + return WorldStatus( + queue_url=queue_urls[0], + login_server=login_servers[0], + allowed_billing_roles=allowed_billing_roles, + denied_billing_roles=denied_billing_roles, + ) async def _get_status_dict(self, status_server_url: str) -> dict[str, Any]: """Return world status dictionary From a2c9c9ee0d294f5bb0e9301675d15a5a22bbb9f6 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Sun, 28 Dec 2025 14:47:32 -0600 Subject: [PATCH 87/97] docs: add macOS installation section --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4cc0d0da..e7f76bc3 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,19 @@ An enhanced launcher for both [LOTRO](https://www.lotro.com/) and [DDO](https:// The easiest way to get OneLauncher is with a [compiled release](https://Github.com/JuneStepp/OneLauncher/releases/latest). It can also be run with Python or Nix. - [Latest Release](https://Github.com/JuneStepp/OneLauncher/releases/latest) +- [macOS Instructions](#macos) - [System Requirements](#system-requirements) -- [Running from source code](CONTRIBUTING.md#development-install) +- [Running From Source Code](CONTRIBUTING.md#development-install) + +### macOS + +- Download the latest release: + - [arm64 (Apple Silicon)](http://github.com/JuneStepp/OneLauncher/releases/latest/download/OneLauncher-macOS-ARM64.zip) + - [x86_64 (Intel)](http://github.com/JuneStepp/OneLauncher/releases/latest/download/OneLauncher-macOS-x86_64.zip) +- Double click the `OneLauncher-macOS-*.zip` file to extract it. +- Drag the extracted `OneLauncher` to your Applications folder if you'd like. +- You can double click `OneLauncher` to open it. +- If you see a message like "OneLauncher can't be opened because it is from an unidentified developer", you'll have to go to the Privacy and Security section of your System Settings where there will be an option to allow opening OneLauncher. ### System Requirements From b09bc808f4435699325a62b2fcd04ef53031492c Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 29 Dec 2025 17:56:42 -0600 Subject: [PATCH 88/97] build: use `--sequesterRsrc` with `ditto` --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82654763..c1816bb1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -165,7 +165,7 @@ jobs: fi - name: Make app zip on MacOS if: runner.os == 'macOS' - run: ditto -c -k --keepParent build/out/onelauncher.app build/out/onelauncher.zip + run: ditto -c -k --sequesterRsrc --keepParent build/out/onelauncher.app build/out/onelauncher.zip - name: Rename artifact run: mv build/out/${{ matrix.artifact_path_name }} build/out/${{ matrix.artifact_rename }} - name: Upload build artifact From 4ce544c699d50d29b5e349d92ad6c0951dc651cf Mon Sep 17 00:00:00 2001 From: June Stepp Date: Mon, 29 Dec 2025 18:17:26 -0600 Subject: [PATCH 89/97] fix(patch_game): adjust limits and timeouts --- src/onelauncher/network/httpx_client.py | 5 +++-- src/onelauncher/official_clients.py | 3 +++ src/onelauncher/patch_game.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/onelauncher/network/httpx_client.py b/src/onelauncher/network/httpx_client.py index 1e0ba2ac..e64293dd 100644 --- a/src/onelauncher/network/httpx_client.py +++ b/src/onelauncher/network/httpx_client.py @@ -10,17 +10,18 @@ ) CONNECTION_RETRIES: Final[int] = 3 +LIMITS: Final = httpx.Limits(max_connections=12) @cache def _get_default_httpx_client() -> httpx.AsyncClient: transport = httpx.AsyncHTTPTransport(retries=CONNECTION_RETRIES) - return httpx.AsyncClient(transport=transport) + return httpx.AsyncClient(limits=LIMITS, transport=transport) @cache def _get_default_httpx_client_sync() -> httpx.Client: - transport = httpx.HTTPTransport(retries=CONNECTION_RETRIES) + transport = httpx.HTTPTransport(retries=CONNECTION_RETRIES, limits=LIMITS) return httpx.Client(transport=transport) diff --git a/src/onelauncher/official_clients.py b/src/onelauncher/official_clients.py index 21c46fbb..b5a4cb93 100644 --- a/src/onelauncher/official_clients.py +++ b/src/onelauncher/official_clients.py @@ -80,6 +80,7 @@ CONNECTION_RETRIES: Final[int] = 3 TIMEOUT: Final = httpx.Timeout(timeout=6.0, read=10.0) +LIMITS: Final = httpx.Limits(max_connections=6) def is_official_game_server(url: str) -> bool: @@ -149,6 +150,7 @@ def get_official_servers_httpx_client() -> httpx.AsyncClient: transport = httpx.AsyncHTTPTransport(retries=CONNECTION_RETRIES) return httpx.AsyncClient( timeout=TIMEOUT, + limits=LIMITS, event_hooks={"request": [_httpx_request_hook]}, transport=transport, ) @@ -160,6 +162,7 @@ def get_official_servers_httpx_client_sync() -> httpx.Client: transport = httpx.HTTPTransport(retries=CONNECTION_RETRIES) return httpx.Client( timeout=TIMEOUT, + limits=LIMITS, event_hooks={"request": [_httpx_request_hook_sync]}, transport=transport, ) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index ca91c45e..bf86d5f4 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -192,7 +192,7 @@ async def _handle_akamai_download_file( # Using the `async with client.stream(...)` currently doesn't work with # Nuitka. See . request = get_httpx_client(url).build_request( - "GET", url, timeout=httpx.Timeout(6, pool=None) + "GET", url, timeout=httpx.Timeout(20, pool=None) ) response = await get_httpx_client(url).send(request, stream=True) try: From c9123a5577882c20f22d391bdbf6185533093418 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 15:20:11 -0600 Subject: [PATCH 90/97] refactor: remove `processEvents` call for game banner It doesn't seem to work anymore. Not with a Trio checkpoint either. --- src/onelauncher/main_window.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index 2587ee5e..a0f3bee5 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -935,13 +935,6 @@ async def InitialSetup(self) -> None: return self.resetFocus() - # Without this, it will take a sec for the game banner geometry to adjust to the - # image size. That behavior didn't look nice. The events are processed here, - # because starting the Trio stuff is where the slowdown is. - get_qapp().processEvents( - QtCore.QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents - | QtCore.QEventLoop.ProcessEventsFlag.ExcludeSocketNotifiers - ) async with trio.open_nursery() as self.network_setup_nursery: self.network_setup_nursery.start_soon(self.game_initial_network_setup) From c6a5b4607471cd18fdc0d442c4af73f60b2056f5 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 15:30:35 -0600 Subject: [PATCH 91/97] feat: add `on_game_start="close"` option --- README.md | 6 ++- src/onelauncher/cli.py | 13 +++-- src/onelauncher/main_window.py | 66 ++++++++++++++++------- src/onelauncher/program_config.py | 7 +++ src/onelauncher/settings_window.py | 8 +++ src/onelauncher/start_game.py | 3 ++ src/onelauncher/ui/settings_window.ui | 35 ++++++++++-- src/onelauncher/ui/settings_window_uic.py | 29 ++++++++-- 8 files changed, 136 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e7f76bc3..11d129df 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Environment variables can also be used. For example, --config-directory can be set with ONELAUNCHER_CONFIG_DIRECTORY. ╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ --help -h Display this message and exit. │ +│ --help (-h) Display this message and exit. │ │ --install-completion Install shell completion for this application. │ │ --version Display application version. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -82,6 +82,8 @@ set with ONELAUNCHER_CONFIG_DIRECTORY. │ ault-locale-for-ui │ │ --games-sorting-mode Order to show games in UI [choices: priority, │ │ last-played, alphabetical] │ +│ --on-game-start What OneLauncher should do when a game is │ +│ started [choices: stay, close] │ │ --log-verbosity Minimum log severity that will be shown in the │ │ console and log file [choices: debug, info, │ │ warning, error, critical] │ @@ -101,7 +103,7 @@ set with ONELAUNCHER_CONFIG_DIRECTORY. │ --game-settings-directory Custom game settings directory. This is where │ │ user preferences, screenshots, and addons are │ │ stored. │ -│ --newsfeed URL of the feed (RSS, ATOM, ect) to show in │ +│ --newsfeed URL of the feed (RSS, ATOM, etc) to show in │ │ the launcher │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Game Account Options ───────────────────────────────────────────────────────╮ diff --git a/src/onelauncher/cli.py b/src/onelauncher/cli.py index 3b32e654..89972d5b 100644 --- a/src/onelauncher/cli.py +++ b/src/onelauncher/cli.py @@ -41,7 +41,7 @@ from .game_account_config import GameAccountConfig, GameAccountsConfig from .game_config import ClientType, GameConfig, GameConfigID, GameType from .logs import LogLevel, setup_application_logging -from .program_config import GamesSortingMode, ProgramConfig +from .program_config import GamesSortingMode, OnGameStartAction, ProgramConfig from .resources import OneLauncherLocale from .ui import qtdesigner from .utilities import CaseInsensitiveAbsolutePath @@ -80,6 +80,7 @@ def _merge_program_config( default_locale: OneLauncherLocale | None, always_use_default_locale_for_ui: bool | None, games_sorting_mode: GamesSortingMode | None, + on_game_start: OnGameStartAction | None, log_verbosity: LogLevel | None, ) -> ProgramConfig: """ @@ -88,13 +89,14 @@ def _merge_program_config( """ return attrs.evolve( program_config, - default_locale=(default_locale or program_config.default_locale), + default_locale=default_locale or program_config.default_locale, always_use_default_locale_for_ui=( always_use_default_locale_for_ui if always_use_default_locale_for_ui is not None else program_config.always_use_default_locale_for_ui ), - games_sorting_mode=(games_sorting_mode or program_config.games_sorting_mode), + games_sorting_mode=games_sorting_mode or program_config.games_sorting_mode, + on_game_start=on_game_start or program_config.on_game_start, log_verbosity=( log_verbosity if log_verbosity is not None else program_config.log_verbosity ), @@ -369,6 +371,10 @@ def meta( GamesSortingMode | None, Parameter(group=ProgramGroup, help=prog_help("games_sorting_mode")), ] = None, + on_game_start: Annotated[ + OnGameStartAction | None, + Parameter(group=ProgramGroup, help=prog_help("on_game_start")), + ] = None, log_verbosity: Annotated[ LogLevel | None, Parameter(group=ProgramGroup, help=prog_help("log_verbosity")), @@ -389,6 +395,7 @@ def meta( default_locale=default_locale, always_use_default_locale_for_ui=always_use_default_locale_for_ui, games_sorting_mode=games_sorting_mode, + on_game_start=on_game_start, log_verbosity=log_verbosity, ) nonlocal _game_id diff --git a/src/onelauncher/main_window.py b/src/onelauncher/main_window.py index a0f3bee5..8a98c0ec 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -44,6 +44,8 @@ from typing_extensions import override from xmlschema import XMLSchemaValidationError +from onelauncher.async_utils import app_cancel_scope + from . import __about__, addon_manager_window from .addons.startup_script import run_startup_script from .config_manager import ConfigManager, NoValidGamesError @@ -415,7 +417,7 @@ def run_startup_scripts(self) -> None: async def start_game_button_clicked(self) -> None: if self.game_cancel_scope: - logger.info("Game aborted") + logger.info("Aborting game") self.game_cancel_scope.cancel() return @@ -732,28 +734,54 @@ async def start_game(self, game_launcher_config: GameLauncherConfig) -> None: # self.ui.btnSwitchGame.setEnabled(False) self.ui.actionPatch.setEnabled(False) self.ui.btnOptions.setEnabled(False) - with trio.CancelScope() as self.game_cancel_scope: - try: - return_code = await start_game( - config_manager=self.config_manager, - game_id=self.game_id, - game_launcher_config=game_launcher_config, - game_launcher_local_config=self.game_launcher_local_config, - world=selected_world, - login_server=selected_world_status.login_server, - account_number=account_number, - ticket=login_response.session_ticket, + program_config = self.config_manager.get_program_config() + windows_visible_before_start = tuple( + widget for widget in get_qapp().topLevelWidgets() if widget.isVisible() + ) + try: + async with trio.open_nursery() as nursery: + self.game_cancel_scope = nursery.cancel_scope + + process: trio.Process = await nursery.start( + partial( + start_game, + config_manager=self.config_manager, + game_id=self.game_id, + game_launcher_config=game_launcher_config, + game_launcher_local_config=self.game_launcher_local_config, + world=selected_world, + login_server=selected_world_status.login_server, + account_number=account_number, + ticket=login_response.session_ticket, + ) ) - if return_code != 0: + if ( + program_config.on_game_start == "close" + and process.returncode is None + ): + # We hide and close after the game finishes rather than literally + # closing when the game starts. + for widget in windows_visible_before_start: + widget.hide() + + if await process.wait() != 0: logger.error("Game closed unexpectedly") else: logger.info("Game finished") - except* MissingLaunchArgumentError: - logger.exception( - "Game launch argument missing. Please report this error if using a supported server." - ) - except* OSError: - logger.exception("Failed to start game") + if program_config.on_game_start == "close": + app_cancel_scope.cancel() + await trio.lowlevel.checkpoint_if_cancelled() + except* MissingLaunchArgumentError: + logger.exception( + "Game launch argument missing. Please report this error if using a supported server." + ) + except* OSError: + logger.exception("Failed to start game") + + # Show windows again, because there was an error. + if program_config.on_game_start == "close": + for widget in windows_visible_before_start: + widget.show() self.game_cancel_scope = None self.reset_start_game_button() diff --git a/src/onelauncher/program_config.py b/src/onelauncher/program_config.py index bcbc1c37..67117304 100644 --- a/src/onelauncher/program_config.py +++ b/src/onelauncher/program_config.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Literal, TypeAlias import attrs from packaging.version import Version @@ -25,6 +26,9 @@ class GamesSortingMode(Enum): ALPHABETICAL = "alphabetical" +OnGameStartAction: TypeAlias = Literal["stay", "close"] + + @attrs.frozen class ProgramConfig(Config): default_locale: OneLauncherLocale = config_field( @@ -37,6 +41,9 @@ class ProgramConfig(Config): games_sorting_mode: GamesSortingMode = config_field( default=GamesSortingMode.PRIORITY, help="Order to show games in UI" ) + on_game_start: OnGameStartAction = config_field( + default="stay", help=f"What {__title__} should do when a game is started" + ) log_verbosity: LogLevel | None = config_field( default=None, help="Minimum log severity that will be shown in the console and log file", diff --git a/src/onelauncher/settings_window.py b/src/onelauncher/settings_window.py index b0bcda40..99f23eda 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -155,6 +155,7 @@ def setup_ui(self) -> None: self.ui.highResCheckBox.setChecked(game_config.high_res_enabled) + # Program config page program_config = self.config_manager.read_program_config_file() self.add_languages_to_combobox(self.ui.gameLanguageComboBox) self.ui.gameLanguageComboBox.setCurrentText( @@ -183,6 +184,10 @@ def setup_ui(self) -> None: self.ui.gamesSortingModeComboBox.findData(program_config.games_sorting_mode) ) + self.ui.closeAfterStartingGameCheckBox.setChecked( + program_config.on_game_start == "close" + ) + self.ui.setupWizardButton.clicked.connect( lambda: self.nursery.start_soon(self.start_setup_wizard) ) @@ -532,6 +537,9 @@ def save_config(self) -> None: self.ui.defaultLanguageForUICheckBox.isChecked() ), games_sorting_mode=(self.ui.gamesSortingModeComboBox.currentData()), + on_game_start="close" + if self.ui.closeAfterStartingGameCheckBox.isChecked() + else "stay", ) ) diff --git a/src/onelauncher/start_game.py b/src/onelauncher/start_game.py index 38272d73..f0812468 100644 --- a/src/onelauncher/start_game.py +++ b/src/onelauncher/start_game.py @@ -208,6 +208,7 @@ async def update_game_user_preferences( async def start_game( + *, config_manager: ConfigManager, game_id: GameConfigID, game_launcher_config: GameLauncherConfig, @@ -216,6 +217,7 @@ async def start_game( login_server: str, account_number: str, ticket: str, + task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED, ) -> int: """ Raises: @@ -291,4 +293,5 @@ async def start_game( nursery.start_soon( partial(for_each_in_stream, process.stderr, process_logging_adapter.warning) ) + task_status.started(process) # type: ignore[call-overload] return await process.wait() diff --git a/src/onelauncher/ui/settings_window.ui b/src/onelauncher/ui/settings_window.ui index a665b6b9..e9cbb075 100644 --- a/src/onelauncher/ui/settings_window.ui +++ b/src/onelauncher/ui/settings_window.ui @@ -598,7 +598,36 @@ - + + + + Close OneLauncher when a game is started + + + Close After Starting Game + + + Qt::AlignmentFlag::AlignCenter + + + true + + + + + + + + 0 + 0 + + + + Close OneLauncher when a game is started + + + + @@ -611,7 +640,7 @@ - + @@ -624,7 +653,7 @@ - + Qt::Orientation::Vertical diff --git a/src/onelauncher/ui/settings_window_uic.py b/src/onelauncher/ui/settings_window_uic.py index dc70ffd8..b6606afe 100644 --- a/src/onelauncher/ui/settings_window_uic.py +++ b/src/onelauncher/ui/settings_window_uic.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'settings_window.ui' ## -## Created by: Qt User Interface Compiler version 6.10.1 +## Created by: Qt User Interface Compiler version 6.10.0 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -317,23 +317,37 @@ def setupUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> None: self.formLayout_4.setWidget(2, QFormLayout.ItemRole.FieldRole, self.gamesSortingModeComboBox) + self.closeAfterStartingGameLabel = FixedWordWrapQLabel(self.pageProgram) + self.closeAfterStartingGameLabel.setObjectName(u"closeAfterStartingGameLabel") + self.closeAfterStartingGameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.closeAfterStartingGameLabel.setWordWrap(True) + + self.formLayout_4.setWidget(4, QFormLayout.ItemRole.LabelRole, self.closeAfterStartingGameLabel) + + self.closeAfterStartingGameCheckBox = QCheckBox(self.pageProgram) + self.closeAfterStartingGameCheckBox.setObjectName(u"closeAfterStartingGameCheckBox") + sizePolicy1.setHeightForWidth(self.closeAfterStartingGameCheckBox.sizePolicy().hasHeightForWidth()) + self.closeAfterStartingGameCheckBox.setSizePolicy(sizePolicy1) + + self.formLayout_4.setWidget(4, QFormLayout.ItemRole.FieldRole, self.closeAfterStartingGameCheckBox) + self.gamesManagementButton = QPushButton(self.pageProgram) self.gamesManagementButton.setObjectName(u"gamesManagementButton") sizePolicy.setHeightForWidth(self.gamesManagementButton.sizePolicy().hasHeightForWidth()) self.gamesManagementButton.setSizePolicy(sizePolicy) - self.formLayout_4.setWidget(3, QFormLayout.ItemRole.FieldRole, self.gamesManagementButton) + self.formLayout_4.setWidget(5, QFormLayout.ItemRole.FieldRole, self.gamesManagementButton) self.setupWizardButton = QPushButton(self.pageProgram) self.setupWizardButton.setObjectName(u"setupWizardButton") sizePolicy.setHeightForWidth(self.setupWizardButton.sizePolicy().hasHeightForWidth()) self.setupWizardButton.setSizePolicy(sizePolicy) - self.formLayout_4.setWidget(4, QFormLayout.ItemRole.FieldRole, self.setupWizardButton) + self.formLayout_4.setWidget(6, QFormLayout.ItemRole.FieldRole, self.setupWizardButton) self.verticalSpacer_4 = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) - self.formLayout_4.setItem(5, QFormLayout.ItemRole.FieldRole, self.verticalSpacer_4) + self.formLayout_4.setItem(7, QFormLayout.ItemRole.FieldRole, self.verticalSpacer_4) self.stackedWidget.addWidget(self.pageProgram) @@ -497,6 +511,13 @@ def retranslateUi(self, settingsWindow: FramelessQDialogWithStylePreview) -> Non #endif // QT_CONFIG(tooltip) self.defaultLanguageForUICheckBox.setText("") self.gamesSortingModeLabel.setText(QCoreApplication.translate("settingsWindow", u"Games Sorting Mode", None)) +#if QT_CONFIG(tooltip) + self.closeAfterStartingGameLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Close OneLauncher when a game is started", None)) +#endif // QT_CONFIG(tooltip) + self.closeAfterStartingGameLabel.setText(QCoreApplication.translate("settingsWindow", u"Close After Starting Game", None)) +#if QT_CONFIG(tooltip) + self.closeAfterStartingGameCheckBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Close OneLauncher when a game is started", None)) +#endif // QT_CONFIG(tooltip) self.gamesManagementButton.setText(QCoreApplication.translate("settingsWindow", u"Manage Games", None)) self.setupWizardButton.setText(QCoreApplication.translate("settingsWindow", u"Run Setup Wizard", None)) #if QT_CONFIG(tooltip) From 3bd7008e163383db3dae011e6f6f721e2105016f Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 15:32:09 -0600 Subject: [PATCH 92/97] fix(cli): fix help page entries for parameters using `_cattrs_converter` --- src/onelauncher/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onelauncher/cli.py b/src/onelauncher/cli.py index 89972d5b..a4886d44 100644 --- a/src/onelauncher/cli.py +++ b/src/onelauncher/cli.py @@ -60,7 +60,7 @@ class _GameParamGameType(Enum): _ConverterTypeVar = TypeVar("_ConverterTypeVar", bound=type) -@Parameter(n_tokens=1) +@Parameter(n_tokens=1, accepts_keys=False) def _cattrs_converter( type_: type[_ConverterTypeVar], tokens: Sequence[Token] ) -> _ConverterTypeVar: From 9a92261479fec93f1ff09f0cd9c1700573ed5987 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 15:51:30 -0600 Subject: [PATCH 93/97] docs: add note for macOS players that first load may have brief stutters --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 11d129df..1e50546d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ The easiest way to get OneLauncher is with a [compiled release](https://Github.c - You can double click `OneLauncher` to open it. - If you see a message like "OneLauncher can't be opened because it is from an unidentified developer", you'll have to go to the Privacy and Security section of your System Settings where there will be an option to allow opening OneLauncher. +Have fun adventuring! Note that there may be some stuttering the first time you play. +This is expected and will go away quickly. + ### System Requirements #### Windows From 520b9e2ee6fffdca53ead2cbb3983913d737beb9 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 18:37:42 -0600 Subject: [PATCH 94/97] build(nuitka_compile): improve how output dir is specified --- build/nuitka_compile.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index b068ab43..041ae11e 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -15,14 +15,14 @@ def get_dist_dir_name() -> str: def main( - out_dir: Path | None = None, + out_dir: Path = Path(__file__).parent / "out", onefile_mode: bool = False, nuitka_deployment_mode: bool = False, extra_args: Iterable[str] = (), ) -> None: nuitka_arguments = [ f"--user-package-configuration-file={Path(__file__).parent / 'nuitka_package_config.yml'}", - f"--output-dir={Path(__file__).parent / 'out'}", + f"--output-dir={out_dir}", "--onefile" if onefile_mode else "--standalone", "--python-flag=-m", # Package mode. Compile as "package.__main__" "--python-flag=isolated", @@ -43,8 +43,6 @@ def main( f"--file-description={__about__.__title__}", f"--copyright={__about__.__copyright__}", ] - if out_dir: - nuitka_arguments.append(f"--output-dir={out_dir}") if nuitka_deployment_mode: nuitka_arguments.append("--deployment") if sys.platform != "win32": From cc3f6eb728538bdea17058ae89f56d600f0086ce Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 18:39:41 -0600 Subject: [PATCH 95/97] build(nuitka_compile): don't exclude asyncio It is used by Trio when running processes. --- build/nuitka_compile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index 041ae11e..608bb0be 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -28,7 +28,7 @@ def main( "--python-flag=isolated", "--python-flag=no_docstrings", "--warn-unusual-code", - "--nofollow-import-to=tkinter,pydoc,pdb,PySide6.QtOpenGL,PySide6.QtOpenGLWidgets,zstandard,asyncio,anyio._backends._asyncio,smtplib,requests,requests_file", + "--nofollow-import-to=tkinter,pydoc,pdb,PySide6.QtOpenGL,PySide6.QtOpenGLWidgets,zstandard,smtplib,requests,requests_file", "--noinclude-setuptools-mode=nofollow", "--noinclude-unittest-mode=nofollow", "--noinclude-pytest-mode=nofollow", From 8b756ddacaa5582ac7cd425976f952d57c2ed31b Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 18:45:32 -0600 Subject: [PATCH 96/97] docs: improve readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e50546d..90bf89d7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ An enhanced launcher for both [LOTRO](https://www.lotro.com/) and [DDO](https:// - Password saving - Plugins, skins, and music manager - External scripting support for addons -- Auto WINE setup for Mac and Linux +- Auto WINE setup for Linux and macOS - Multiple clients support - *more* From 9bc4ff88ca65bb3f92ccd4ff2d9d91cad6a0d825 Mon Sep 17 00:00:00 2001 From: June Stepp Date: Tue, 30 Dec 2025 19:23:44 -0600 Subject: [PATCH 97/97] fix(patch_game): remove old file before renaming --- src/onelauncher/patch_game.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/onelauncher/patch_game.py b/src/onelauncher/patch_game.py index bf86d5f4..d1e2a763 100644 --- a/src/onelauncher/patch_game.py +++ b/src/onelauncher/patch_game.py @@ -229,6 +229,7 @@ async def _handle_akamai_download_file( progress.progress_items.remove(progress_item) else: await local_path.parent.mkdir(parents=True, exist_ok=True) + await local_path.unlink(missing_ok=True) await temp_download_path.rename(local_path) finally: with trio.move_on_after(5, shield=True):