diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95fcc1c3..c1816bb1 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,32 +10,43 @@ on: jobs: build: - name: Build for ${{ matrix.os }} + name: ${{ matrix.os }} 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 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 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 - 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 @@ -44,24 +54,75 @@ jobs: if: runner.os == 'Windows' shell: msys2 {0} run: make -C src/run_patch_client + - 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 - # uv + 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 # See https://github.com/Nuitka/Nuitka/issues/3331 - name: Install Python - uses: actions/setup-python@v5 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: - python-version: "3.11" + python-version-file: "pyproject.toml" - name: Install uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 with: activate-environment: true - run: uv sync --locked --no-dev --group build - # Nuitka cache - - name: Install ccache for Nuitka + # Nuitka cache. Based on . + - 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: | @@ -69,44 +130,65 @@ 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: ${{ 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' - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1 with: dotnet-version: 8.0.x - 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 --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 - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ matrix.artifact_rename }} 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 + 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 new file mode 100644 index 00000000..3696ad21 --- /dev/null +++ b/.github/workflows/status_checks.yml @@ -0,0 +1,44 @@ +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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Install Python + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 + with: + python-version-file: "pyproject.toml" + - name: Install uv + uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6 + 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 for spelling mistakes + run: typos + - name: Check formatting + run: ruff format --check --output-format=github 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/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/CONTRIBUTING.md b/CONTRIBUTING.md index 844badc3..13421a92 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,13 +9,14 @@ 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 && 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 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 diff --git a/README.md b/README.md index 227ff8ac..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 Linux +- Auto WINE setup for Linux and macOS - Multiple clients support - *more* @@ -21,8 +21,22 @@ 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. + +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 @@ -42,50 +56,79 @@ 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] │ +│ --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] │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ 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, etc) 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/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( diff --git a/build/nuitka_compile.py b/build/nuitka_compile.py index 7ee702d2..608bb0be 100644 --- a/build/nuitka_compile.py +++ b/build/nuitka_compile.py @@ -15,24 +15,25 @@ 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"--output-dir={Path(__file__) / 'out'}", + f"--user-package-configuration-file={Path(__file__).parent / 'nuitka_package_config.yml'}", + f"--output-dir={out_dir}", "--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", - "--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", "--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", @@ -42,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": @@ -63,6 +62,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/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 725ab13e..f7bee7b3 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,10 @@ }; default = self.packages.${system}.onelauncher; fhs-run = - (pkgs.steam-fhsenv-without-steam.override { - # steam-unwrapped = null; + (pkgs.steam.override { extraPkgs = pkgs: [ pkgs.libz ]; - }).run; + privateTmp = false; + }).run-free; }; apps = { onelauncher = flake-utils.lib.mkApp { drv = self.packages.${system}.onelauncher; }; @@ -223,7 +223,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 = @@ -243,6 +243,21 @@ pkgs.mkShell { packages = [ virtualenv + pkgs.innoextract + (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 @@ -271,7 +286,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/pyproject.toml b/pyproject.toml index ac60c935..841cd4b1 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,23 +47,31 @@ 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", + "mypy>=1.19.1", "types-cachetools>=5.3.0.7", - "ruff>=0.14.1", - # Newer versions have better types + "ruff>=0.14.10", + # Newer versions have more accurate types. "PySide6-Essentials>=6.10.0", + "typos>=1.40.0", ] 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", + # For converting the icon image. + "ImageIO>=2.37.2 ; sys_platform == 'darwin'", ] -build = ["Nuitka>=2.4.8", "marko>=2.1.2"] dev = [ { include-group = "lint" }, { include-group = "test" }, @@ -108,12 +116,15 @@ select = [ "ERA", # eradicate "PL", # Pylint "PT", # flake8-pytest-style + "LOG", # flake8-logging + "G", # flake8-logging-format ] 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 @@ -127,6 +138,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"] @@ -155,3 +167,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/__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/addon_manager.py b/src/onelauncher/addon_manager_window.py similarity index 90% rename from src/onelauncher/addon_manager.py rename to src/onelauncher/addon_manager_window.py index be42b0f9..076ff725 100644 --- a/src/onelauncher/addon_manager.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 @@ -63,10 +64,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 +71,10 @@ from .game_config import GameConfigID, GameType from .game_launcher_local_config import GameLauncherLocalConfig from .game_utilities import get_game_settings_dir -from .ui.addon_manager_uic import Ui_winAddonManager +from .network.httpx_client import get_httpx_client_sync +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 if TYPE_CHECKING: @@ -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 @@ -219,7 +222,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) @@ -295,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") @@ -325,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) @@ -357,12 +365,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() ] @@ -384,13 +392,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: @@ -410,12 +411,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() ] @@ -455,11 +454,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: @@ -483,12 +477,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() ] @@ -518,9 +510,10 @@ 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( - f"`.plugincompendium` file has invalid XML: {compendium_file}", + "`.plugincompendium` file has invalid XML: %s", + compendium_file, exc_info=True, ) continue @@ -540,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, @@ -549,11 +544,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 @@ -586,8 +576,8 @@ 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}") + except ExpatError: + logger.exception("Compendium file has invalid XML: %s", file) return None nodes = doc.getElementsByTagName(tag)[0].childNodes for node in nodes: @@ -648,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( @@ -733,16 +723,21 @@ 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) return 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 " - "program to extract" + "%s does not support .rar archives, because it" + " is a proprietary format that would require an external " + "program to extract", + __title__, ) return elif addon_path.suffix == ".zip": @@ -754,14 +749,17 @@ 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 - self.ui.tableMusicInstalled.clearContents() - self.getInstalledMusic() + # so what is scanned can't be controlled. + self.getInstalledMusic(folders_list=None) - 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 +784,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 +853,7 @@ def install_plugin( compendium_file = self.generateCompendiumFile( author_folder, interface_id, - "Plugin", + "plugin", table.objectName(), existing_compendium_file, ) @@ -885,12 +885,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}" + "Installed plugin corresponding to %s %s", 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 +914,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 +940,7 @@ def install_music( self.generateCompendiumFile( root_dir, interface_id, - "Music", + "music", table.objectName(), existing_compendium_file, ) @@ -947,15 +951,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}") + logger.debug("%s music installed at %s", addon_name, 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 +977,7 @@ def install_skin( self.generateCompendiumFile( root_dir, interface_id, - "Skin", + "skin", table.objectName(), existing_compendium_file, ) @@ -980,18 +988,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}") + logger.debug("%s skin installed at %s", addon_name, 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,12 +1010,19 @@ 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 - (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) @@ -1054,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: @@ -1102,7 +1118,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: @@ -1116,9 +1132,9 @@ 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 (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 @@ -1314,9 +1330,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""" @@ -1324,7 +1338,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( @@ -1333,12 +1347,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( @@ -1358,10 +1372,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() @@ -1442,17 +1456,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 +1472,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() @@ -1548,7 +1560,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], ) ) @@ -1559,6 +1573,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])] @@ -1567,9 +1583,10 @@ def uninstallPlugins( if self.checkAddonForDependencies(plugin, table): try: doc = defusedxml.minidom.parse(plugin[1]) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.warning( - f"`.plugincompendium` file has invalid XML: {plugin[1]}", + "`.plugincompendium` file has invalid XML: %s", + plugin[1], exc_info=True, ) continue @@ -1597,9 +1614,10 @@ def uninstallPlugins( if plugin_file.exists(): try: doc = defusedxml.minidom.parse(str(plugin_file)) - except xml.parsers.expat.ExpatError: + except ExpatError: logger.warning( - f"`.plugin` file has invalid XML: {plugin_file}", + "`.plugin` file has invalid XML: %s", + plugin_file, exc_info=True, ) else: @@ -1617,21 +1635,21 @@ 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") + logger.debug("%s plugin uninstalled", plugin) self.setRemoteAddonToUninstalled(plugin, self.ui.tablePlugins) - # Reloads plugins - table.clearContents() 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 @@ -1646,17 +1664,17 @@ 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) - # Reloads skins - table.clearContents() self.getInstalledSkins() 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 @@ -1673,12 +1691,10 @@ def uninstallMusic( else: rmtree(music_path) - logger.debug(f"{music} music uninstalled") + logger.debug("%s music uninstalled", music) self.setRemoteAddonToUninstalled(music, self.ui.tableMusic) - # Reloads music - table.clearContents() self.getInstalledMusic() def checkAddonForDependencies( @@ -1749,15 +1765,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: @@ -1785,9 +1801,9 @@ 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.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()) @@ -1813,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`. @@ -1855,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." ) @@ -1905,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) @@ -1962,7 +1980,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) @@ -1980,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, @@ -2085,7 +2103,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 +2113,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 +2127,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) @@ -2126,40 +2144,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] ) @@ -2258,21 +2285,17 @@ 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() 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 ) @@ -2285,9 +2308,11 @@ def getOutOfDateAddons(self) -> None: ) } - for addon in 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. @@ -2331,21 +2356,23 @@ 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 == 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 addon in self.c.execute( - f"SELECT InterfaceID, File, Name FROM {table.objectName()} WHERE Version LIKE '(Outdated) %'" # noqa: S608 + for table in tables: + for addon in tuple( + self.c.execute( + f"SELECT InterfaceID, File, Name FROM {table.objectName()} WHERE Version LIKE '(Outdated) %'" # noqa: S608 + ) ): self.updateAddon( Addon( @@ -2374,13 +2401,10 @@ 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: - if not self.loadRemoteDataIfNotDone(): - return - table = self.context_menu_selected_table row = self.context_menu_selected_row addon = self.getAddonObjectFromRow(table, row, remote=False) @@ -2397,9 +2421,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): @@ -2421,18 +2442,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.isTableEmpty(self.ui.tableSkins): - self.getOutOfDateAddons() - return True - def actionEnableStartupScriptSelected(self) -> None: if not self.context_menu_selected_interface_ID: return @@ -2457,7 +2466,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: @@ -2486,9 +2496,11 @@ 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( - 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("\\", "/") @@ -2498,18 +2510,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 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/async_utils.py b/src/onelauncher/async_utils.py index 62211c7a..c236d1a3 100644 --- a/src/onelauncher/async_utils.py +++ b/src/onelauncher/async_utils.py @@ -1,16 +1,22 @@ import logging from collections.abc import Awaitable, Callable -from typing import Any +from functools import partial +from tempfile import TemporaryDirectory +from types import TracebackType +from typing import Final import outcome import trio from PySide6 import QtCore, QtWidgets +from trio.abc import ReceiveStream from typing_extensions import override +from .ui.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 +38,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 @@ -50,10 +56,10 @@ 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[[], 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 +70,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 +79,98 @@ 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), + restrict_keyboard_interrupt_to_checkpoints=True, + ) + 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() + + +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) + + +# 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/cli.py b/src/onelauncher/cli.py index 774f889d..a4886d44 100644 --- a/src/onelauncher/cli.py +++ b/src/onelauncher/cli.py @@ -1,122 +1,121 @@ -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 .logs import LogLevel, setup_application_logging +from .program_config import GamesSortingMode, OnGameStartAction, ProgramConfig 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, accepts_keys=False) +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, + on_game_start: OnGameStartAction | None, + log_verbosity: LogLevel | None, ) -> ProgramConfig: """ 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 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 + ), ) -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 +127,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 +170,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 +192,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 +202,7 @@ def merge_game_config( ) -def merge_accounts_config( +def _merge_accounts_config( game_accounts_config: GameAccountsConfig, *, username: str | None, @@ -251,387 +242,341 @@ 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) + + # --- Commands --- + # 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 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, + 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")), + ] = None, + # Game + 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, + on_game_start=on_game_start, + log_verbosity=log_verbosity, ) - 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) + 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( + log_level_override=config_manager.get_program_config().log_verbosity + ) + 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, ) - 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() + 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, + ) + + 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..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,9 +12,12 @@ 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 +from onelauncher.logs import LogLevel + from .__about__ import __title__ from .addons.startup_script import StartupScript from .config import Config, ConfigValWithMetadata, platform_dirs, unstructure_config @@ -22,10 +26,11 @@ 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" +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" def _structure_onelauncher_locale( @@ -34,8 +39,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( @@ -48,18 +53,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) ) @@ -85,6 +110,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 +345,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 +378,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 +408,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 +484,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 +568,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. @@ -577,19 +629,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: @@ -677,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( @@ -726,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, @@ -740,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, @@ -754,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/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/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 bf56dbdd..581f7a44 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( @@ -67,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={}, @@ -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/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..7e530f68 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,8 +61,23 @@ 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 ) + + +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/install_game.py b/src/onelauncher/install_game.py new file mode 100644 index 00000000..91af97b2 --- /dev/null +++ b/src/onelauncher/install_game.py @@ -0,0 +1,289 @@ +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 ( + trio.wrap_file(NamedTemporaryFile()) as installer_file, + TemporaryDirectoryAsyncPath() as extract_dir, + ): + 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 + 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() + 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/logs.py b/src/onelauncher/logs.py index 3ab73e55..58d07a24 100644 --- a/src/onelauncher/logs.py +++ b/src/onelauncher/logs.py @@ -1,6 +1,8 @@ import logging +import os import sys from collections.abc import Callable +from enum import IntEnum from functools import partial from logging.handlers import RotatingFileHandler from pathlib import Path @@ -10,6 +12,9 @@ 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 from .config import platform_dirs @@ -17,10 +22,19 @@ 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__}") + logger.info("%s: %s", __title__, __version__) logger.info(platform()) + logger.info("Data Dir: %s", data_dir) def handle_uncaught_exceptions( @@ -34,9 +48,8 @@ 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)) + app_cancel_scope.cancel() class RedactHomeDirFormatter(logging.Formatter): @@ -51,53 +64,53 @@ 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) - # Create or get custom logger + # 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. - logger.setLevel(logging.DEBUG) + 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) @@ -125,7 +138,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 new file mode 100644 index 00000000..8ad0afb5 --- /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 .setup_wizard import SetupWizard +from .ui.error_message_window_uic import Ui_errorMessageWindow +from .ui.qtapp import get_qapp + +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: Whether the user wants to load the backup. + """ + _ = get_qapp() + dialog = QtWidgets.QDialog() + ui = Ui_errorMessageWindow() + 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: Whether 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) + 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) + + 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) + 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) + + 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..8a98c0ec 100644 --- a/src/onelauncher/main_window.py +++ b/src/onelauncher/main_window.py @@ -28,25 +28,27 @@ from __future__ import annotations import logging +import sys from functools import partial from pathlib import Path from typing import cast 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 -from onelauncher.logs import ForwardLogsHandler -from onelauncher.qtapp import get_app_style, get_qapp -from onelauncher.ui.custom_widgets import FramelessQMainWindowWithStylePreview +from onelauncher.async_utils import app_cancel_scope -from . import __about__, addon_manager -from .config_manager import ConfigManager +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 from .game_config import GameConfigID, GameType from .game_launcher_local_config import ( @@ -57,13 +59,15 @@ from .game_utilities import ( InvalidGameDirError, 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, 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 @@ -73,14 +77,16 @@ 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.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 +from .start_game import MissingLaunchArgumentError, start_game +from .ui.about_window_uic import Ui_aboutWindow +from .ui.custom_widgets import FramelessQMainWindowWithStylePreview +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_window_uic import Ui_selectSubscriptionWindow +from .ui.utilities import log_record_to_rich_text, show_message_box_details_as_markdown logger = logging.getLogger(__name__) @@ -89,35 +95,26 @@ 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_cancel_scope: trio.CancelScope | None = None + self.addon_manager_window: addon_manager_window.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_() self.activateWindow() def setup_ui(self) -> None: - self.ui = Ui_winMain() + self.ui = Ui_mainWindow() self.ui.setupUi(self) logger.addHandler( @@ -128,7 +125,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 ) @@ -163,7 +160,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) ) @@ -177,6 +174,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() @@ -207,7 +213,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 +236,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""" @@ -301,10 +313,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__: @@ -317,7 +329,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: @@ -328,13 +340,12 @@ async def actionPatchSelected(self) -> None: if game_services_info is None: return - winPatch = PatchWindow( + patch_window = PatchGameWindow( 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: @@ -354,7 +365,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, @@ -373,7 +384,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() @@ -383,7 +394,45 @@ 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("Aborting game") + 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() == "" @@ -391,10 +440,6 @@ async def btnLoginClicked(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 laoded") - return - await self.start_game(game_launcher_config=self.game_launcher_config) def accounts_index_changed(self, new_index: int) -> None: @@ -402,7 +447,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 @@ -485,7 +530,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: @@ -536,8 +581,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") @@ -556,7 +601,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: @@ -646,24 +691,103 @@ 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, + # 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 + ) ) - 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, + 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( + 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 login queue") + return + except WorldQueueResultXMLParseError: + logger.exception("Error parsing world login queue response") + return + except JoinWorldQueueFailedError as e: + logger.exception(e.msg) + return + + 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) + program_config = self.config_manager.get_program_config() + windows_visible_before_start = tuple( + widget for widget in get_qapp().topLevelWidgets() if widget.isVisible() ) - await game.start_game() + 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 ( + 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") + 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() + self.ui.btnSwitchGame.setEnabled(True) + self.ui.actionPatch.setEnabled(True) + self.ui.btnOptions.setEnabled(True) async def world_queue( self, @@ -672,6 +796,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, @@ -680,23 +810,13 @@ 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 = ( 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) @@ -716,35 +836,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") @@ -753,22 +896,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.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." - " The standard game launcher can be opened from the settings menu.", - ) - return False - return True async def InitialSetup(self) -> None: @@ -782,7 +909,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) @@ -801,10 +928,26 @@ 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 + 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) @@ -820,13 +963,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) @@ -856,12 +992,16 @@ 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) 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) @@ -889,13 +1029,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: @@ -910,9 +1051,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") @@ -935,7 +1073,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() @@ -963,4 +1101,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/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 3d9f229b..8d73a544 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,41 @@ 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(kw_only=True) 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 + 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: @@ -112,7 +83,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"] @@ -122,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( @@ -147,10 +134,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 suppored client type" - ) from e @classmethod @cached(cache=TTLCache(maxsize=48, ttl=60 * 2)) @@ -174,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 @@ -191,6 +175,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]: @@ -226,70 +222,18 @@ 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 - @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]) diff --git a/src/onelauncher/network/game_newsfeed.py b/src/onelauncher/network/game_newsfeed.py index eff421cc..a282826e 100644 --- a/src/onelauncher/network/game_newsfeed.py +++ b/src/onelauncher/network/game_newsfeed.py @@ -3,20 +3,29 @@ 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.qtapp import get_qapp +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 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/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/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/network/login_account.py b/src/onelauncher/network/login_account.py index a1399ae9..b9b2a39b 100644 --- a/src/onelauncher/network/login_account.py +++ b/src/onelauncher/network/login_account.py @@ -1,15 +1,17 @@ -from typing import Any, NamedTuple, Self +from typing import Any, Self +import attrs import zeep.exceptions 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 @@ -27,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 ( @@ -44,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 @@ -86,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] @@ -103,14 +98,20 @@ 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 +@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 +140,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: 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/network/world.py b/src/onelauncher/network/world.py index 9f34bdbb..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,51 +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: - """URL used to queue for world login. - Will be an empty string, if no queueing is needed.""" - 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 @@ -73,13 +48,36 @@ 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 happening 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]) + + 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 diff --git a/src/onelauncher/network/world_login_queue.py b/src/onelauncher/network/world_login_queue.py index 372ce1f9..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 @@ -12,11 +13,12 @@ class JoinWorldQueueResult(NamedTuple): class WorldQueueResultXMLParseError(Exception): - """Error with content/formatting of world queue respone XML""" + """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: @@ -64,7 +66,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( @@ -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( diff --git a/src/onelauncher/official_clients.py b/src/onelauncher/official_clients.py index 6411a4fc..b5a4cb93 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 @@ -41,45 +40,47 @@ 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_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", -] -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 = ( "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 -# 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) +LIMITS: Final = httpx.Limits(max_connections=6) def is_official_game_server(url: str) -> bool: @@ -90,7 +91,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) ) @@ -143,25 +144,13 @@ 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(), + limits=LIMITS, event_hooks={"request": [_httpx_request_hook]}, transport=transport, ) @@ -170,12 +159,10 @@ 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(), + 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 new file mode 100644 index 00000000..d1e2a763 --- /dev/null +++ b/src/onelauncher/patch_game.py @@ -0,0 +1,439 @@ +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 +from uuid import uuid4 + +import attrs +import httpx +import trio +from httpx import HTTPError, HTTPStatusError +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 external_dependencies_dir +from onelauncher.utilities import CaseInsensitiveAbsolutePath, Progress, ProgressItem +from onelauncher.wine_environment import get_wine_process_args + +logger = logging.getLogger(__name__) +logger.addFilter(ExternalProcessLogsFilter()) + +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 = 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, +because it doesn't expose the stdout of what it runs. +""" + + +class PatchingProgressMonitor: + 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: + 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 _update_progress(self) -> None: + self.progress_item.total = self.total_iterations + self.progress_item.completed = self.current_iterations + + 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" + self._update_progress() + return + elif cleaned_line.startswith("checking data"): + self.patching_type = "data" + 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( + 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(".")) + + self._update_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 _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: + # 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(20, pool=None) + ) + response = await get_httpx_client(url).send(request, stream=True) + try: + 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 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 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) + await local_path.unlink(missing_ok=True) + 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: Progress, + 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 compiled 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 + ) + + # 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: + 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, + ( + *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 + patching_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 deleted file mode 100644 index 14c384ce..00000000 --- a/src/onelauncher/patch_game_window.py +++ /dev/null @@ -1,242 +0,0 @@ -#!/usr/bin/env python3 -########################################################################### -# Patching window 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 os -from typing import Literal, TypeAlias, assert_never - -from PySide6 import QtCore, QtWidgets - -from onelauncher.game_config import GameConfigID -from onelauncher.logs import ExternalProcessLogsFilter, 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 .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, - ): - super().__init__( - get_qapp().activeWindow(), - QtCore.Qt.WindowType.FramelessWindowHint, - ) - self.launcher_local_config = launcher_local_config - - 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( - 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) - - 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 - - # Make sure patch_client exists - if not patch_client.exists(): - logger.error(f"Patch client {patch_client} not found") - 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) - - patch_client_runner = ( - data_dir.parent / "run_patch_client" / "run_ptch_client.exe" - ) - 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]), - ) - - def readOutput(self) -> None: - line = self.process.readAllStandardOutput().toStdString() - if line.strip(): - self.process_logging_adapter.debug(line) - - progress = self.progress_monitor.feed_line(line) - self.ui.progressBar.setMaximum(progress.total_iterations) - self.ui.progressBar.setValue(progress.current_iterations) - - def readErrors(self) -> None: - line = self.process.readAllStandardError().toStdString() - if line.strip(): - self.process_logging_adapter.warning(line) - - def resetButtons(self) -> None: - self.patching_finished = True - self.ui.btnStop.setText("Close") - self.ui.btnStart.setEnabled(True) - self.progress_monitor.reset() - # 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_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() - } - logger.info("*** Started ***") - - def Run(self) -> None: - self.exec() 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/program_config.py b/src/onelauncher/program_config.py index e9f1cff3..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 @@ -6,6 +7,7 @@ from .__about__ import __title__ from .config import Config, config_field +from .logs import LogLevel from .resources import ( OneLauncherLocale, get_default_locale, @@ -24,11 +26,14 @@ class GamesSortingMode(Enum): ALPHABETICAL = "alphabetical" +OnGameStartAction: TypeAlias = Literal["stay", "close"] + + @attrs.frozen 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" @@ -36,6 +41,13 @@ 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", + ) @override @staticmethod diff --git a/src/onelauncher/resources.py b/src/onelauncher/resources.py index cd3c3bb2..a6f1105f 100644 --- a/src/onelauncher/resources.py +++ b/src/onelauncher/resources.py @@ -167,13 +167,14 @@ 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 data_dir = get_data_dir() +external_dependencies_dir = data_dir / "external" available_locales = get_available_locales() system_locale = get_system_locale() 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/settings_window.py b/src/onelauncher/settings_window.py index 8b2330e5..99f23eda 100644 --- a/src/onelauncher/settings_window.py +++ b/src/onelauncher/settings_window.py @@ -25,26 +25,28 @@ # 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 functools import partial from pathlib import Path +from types import MappingProxyType import attrs +import qtawesome import trio 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, @@ -56,10 +58,13 @@ from .setup_wizard import SetupWizard 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.qtapp import get_qapp +from .ui.settings_window_uic import Ui_settingsWindow +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): @@ -75,11 +80,11 @@ 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.setupUi(self) def setup_ui(self) -> None: - self.finished.connect(self.cleanup) + self.ui = Ui_settingsWindow() + self.ui.setupUi(self) + color_scheme_changed = get_qapp().styleHints().colorSchemeChanged self.tab_names = list(TabName) if os.name == "nt": @@ -108,7 +113,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( @@ -138,7 +143,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) ) @@ -151,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( @@ -179,11 +184,24 @@ 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.closeAfterStartingGameCheckBox.setChecked( + program_config.on_game_start == "close" + ) + + 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) + ) + ) + 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.gameDirButton.clicked.connect(self.choose_game_dir) + self.ui.browseForGameDirButton.clicked.connect(self.browse_for_game_install_dir) self.ui.showAdvancedSettingsCheckbox.clicked.connect( self.toggle_advanced_settings ) @@ -193,7 +211,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() @@ -201,6 +219,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) @@ -242,7 +262,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( @@ -281,8 +310,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) @@ -334,18 +361,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.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( @@ -364,7 +401,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 == "": @@ -391,7 +428,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", @@ -400,8 +437,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, @@ -412,8 +454,12 @@ 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() + 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(): @@ -475,7 +521,6 @@ def save_config(self) -> None: ) if self.ui.gameSettingsDirLineEdit.text() else None, - patch_client_filename=self.ui.patchClientLineEdit.text(), ), ) @@ -492,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/setup_wizard.py b/src/onelauncher/setup_wizard.py index a55b56fc..9b63727a 100644 --- a/src/onelauncher/setup_wizard.py +++ b/src/onelauncher/setup_wizard.py @@ -31,15 +31,15 @@ from contextlib import suppress from functools import partial from pathlib import Path +from shutil import rmtree from typing import Final import attrs import qtawesome +import trio 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 @@ -62,8 +62,10 @@ 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.setup_wizard_uic import Ui_Wizard -from .ui_utilities import show_warning_message +from .ui.install_game_window import InstallGameWindow +from .ui.qtapp import get_app_style, get_qapp +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 ( V1xConfigParseError, @@ -123,32 +125,41 @@ 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") + + 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 # 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.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) - self.games_found = False + # Existing game data page self.ui.dataDeletionWizardPage.setCommitPage(True) self.ui.gamesDeletionStatusListView.setModel(self.ui.gamesListWidget.model()) @@ -179,17 +190,20 @@ 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) + 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(): 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 +212,36 @@ 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: + 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 + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self.cleanup() + event.accept() + @override def changeEvent(self, event: QtCore.QEvent) -> None: super().changeEvent(event) @@ -312,16 +356,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 +384,15 @@ def find_games(self) -> None: (home_dir / ".steam/steamapps/compatdata", "*/"), ( home_dir / ".local/share/Steam/steamapps/compatdata/", - "*", + "*/", + ), + ( + home_dir + / "Library" + / "Application Support" + / "Crossover" + / "Bottles", + "*/", ), (home_dir / "games", "*/"), ]: @@ -383,7 +432,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(): @@ -488,9 +536,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 @@ -510,11 +559,24 @@ 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: 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()): @@ -530,6 +592,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 = ( @@ -567,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 ) @@ -582,7 +650,19 @@ 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 + # 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 diff --git a/src/onelauncher/start_game.py b/src/onelauncher/start_game.py index 232e1bb8..f0812468 100644 --- a/src/onelauncher/start_game.py +++ b/src/onelauncher/start_game.py @@ -1,19 +1,33 @@ +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 -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.game_utilities import get_game_user_preferences_path +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 +43,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. @@ -37,7 +51,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. @@ -72,11 +86,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 @@ -124,74 +138,160 @@ 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, "******") for arg in launch_args ) - logger.debug(f"Game launch arguments generated: {redacted_launch_args}") - return launch_args + logger.debug("Game launch arguments generated: %s", redacted_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" -async def get_qprocess( + 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, 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. - + task_status: trio.TaskStatus = trio.TASK_STATUS_IGNORED, +) -> 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 + # 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, ) - # 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 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), + ), + ) + 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) + ) + task_status.started(process) # type: ignore[call-overload] + return await process.wait() 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 cad710e9..00000000 --- a/src/onelauncher/ui/addon_manager.ui +++ /dev/null @@ -1,704 +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 -
-
- - -
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 79% rename from src/onelauncher/ui/addon_manager_uic.py rename to src/onelauncher/ui/addon_manager_window_uic.py index 7d7d1dc1..69b98b90 100644 --- a/src/onelauncher/ui/addon_manager_uic.py +++ b/src/onelauncher/ui/addon_manager_window_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! ################################################################################ @@ -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,62 +324,72 @@ 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) 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) + 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("qssClass", [ - QCoreApplication.translate("winAddonManager", u"icon-lg", None), - QCoreApplication.translate("winAddonManager", u"px-2.5", None), - QCoreApplication.translate("winAddonManager", u"py-1", None)]) + self.btnAddons.setProperty(u"qssClass", [ + 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("qssClass", [ - QCoreApplication.translate("winAddonManager", u"max-h-2", None)]) + self.progressBar.setProperty(u"qssClass", [ + QCoreApplication.translate("addonManagerWindow", u"max-h-2", None)]) # retranslateUi 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/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_window.py b/src/onelauncher/ui/install_game_window.py new file mode 100644 index 00000000..ceb027fc --- /dev/null +++ b/src/onelauncher/ui/install_game_window.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_window_uic import Ui_installGameWindow +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_installGameWindow() + 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_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_window_uic.py b/src/onelauncher/ui/install_game_window_uic.py new file mode 100644 index 00000000..f01fc224 --- /dev/null +++ b/src/onelauncher/ui/install_game_window_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_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(installGameWindow) + 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(installGameWindow) + self.progressBar.setObjectName(u"progressBar") + self.progressBar.setValue(24) + + self.verticalLayout.addWidget(self.progressBar) + + self.buttonBox = QDialogButtonBox(installGameWindow) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setOrientation(Qt.Orientation.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.StandardButton.Cancel) + + self.verticalLayout.addWidget(self.buttonBox) + + + self.retranslateUi(installGameWindow) + self.buttonBox.rejected.connect(installGameWindow.reject) + + QMetaObject.connectSlotsByName(installGameWindow) + # setupUi + + def retranslateUi(self, installGameWindow: FramelessQDialogWithStylePreview) -> None: + installGameWindow.setWindowTitle(QCoreApplication.translate("installGameWindow", u"Install Game", None)) +#if QT_CONFIG(tooltip) + 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("installGameWindow", u"Select game install directory from the file browser", None)) +#endif // QT_CONFIG(tooltip) + self.selectInstallDirButton.setProperty(u"qssClass", [ + QCoreApplication.translate("installGameWindow", u"icon-base", None)]) +#if QT_CONFIG(tooltip) + self.installDirLabel.setToolTip(QCoreApplication.translate("installGameWindow", u"Directory where the game will be installed", None)) +#endif // QT_CONFIG(tooltip) + self.installDirLabel.setText(QCoreApplication.translate("installGameWindow", u"Install Directory", None)) +#if QT_CONFIG(tooltip) + 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("installGameWindow", u"Which game to install", None)) +#endif // QT_CONFIG(tooltip) + 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 1c89bba4..00000000 --- a/src/onelauncher/ui/main.ui +++ /dev/null @@ -1,477 +0,0 @@ - - - winMain - - - - 0 - 0 - 790 - 470 - - - - false - - - - - 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 - - - - - - - 6 - - - - - Select game server - - - - - - - Qt::FocusPolicy::ClickFocus - - - Switch game - - - QToolButton::ToolButtonPopupMode::MenuButtonPopup - - - Qt::ToolButtonStyle::ToolButtonIconOnly - - - - icon-xl - - - - - - - - - - Account - - - - - - - true - - - QComboBox::InsertPolicy::NoInsert - - - - - - - Password - - - - - - - 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 - - - - - - - QMainWindowWithStylePreview - QMainWindow -
.qtdesigner.custom_widgets
- 1 -
- - FramelessQMainWindowWithStylePreview - QMainWindowWithStylePreview -
.custom_widgets
- 1 -
- - GameNewsfeedBrowser - QTextBrowser -
.custom_widgets
-
- - NoOddSizesQToolButton - QToolButton -
.custom_widgets
-
-
- - cboWorld - cboAccount - txtPassword - chkSaveAccount - chkSavePassword - txtFeed - btnMinimize - btnSwitchGame - btnOptions - btnExit - btnAddonManager - btnAbout - txtStatus - - - -
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 68% rename from src/onelauncher/ui/main_uic.py rename to src/onelauncher/ui/main_window_uic.py index b916ef13..715887bd 100644 --- a/src/onelauncher/ui/main_uic.py +++ b/src/onelauncher/ui/main_window_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! ################################################################################ @@ -24,19 +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) - winMain.setUnifiedTitleAndToolBarOnMac(False) - 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.centralwidget = QWidget(winMain) + self.actionAbout = QAction(mainWindow) + self.actionAbout.setObjectName(u"actionAbout") + self.actionAbout.setMenuRole(QAction.MenuRole.AboutRole) + self.actionSettings = QAction(mainWindow) + self.actionSettings.setObjectName(u"actionSettings") + self.actionSettings.setMenuRole(QAction.MenuRole.PreferencesRole) + self.actionExit = QAction(mainWindow) + self.actionExit.setObjectName(u"actionExit") + self.actionExit.setMenuRole(QAction.MenuRole.QuitRole) + self.centralwidget = QWidget(mainWindow) self.centralwidget.setObjectName(u"centralwidget") self.verticalLayout_4 = QVBoxLayout(self.centralwidget) self.verticalLayout_4.setSpacing(3) @@ -183,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") @@ -235,89 +243,101 @@ 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) + 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.btnStartGame) + QWidget.setTabOrder(self.btnStartGame, 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) + 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.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.btnOptions.setToolTip(QCoreApplication.translate("winMain", u"Settings", None)) + self.actionSettings.setToolTip(QCoreApplication.translate("mainWindow", u"Settings", None)) #endif // QT_CONFIG(tooltip) - self.btnOptions.setProperty("qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + self.actionExit.setText(QCoreApplication.translate("mainWindow", u"Exit", None)) #if QT_CONFIG(tooltip) - self.btnAddonManager.setToolTip(QCoreApplication.translate("winMain", u"Addon manager", None)) + self.actionExit.setToolTip(QCoreApplication.translate("mainWindow", u"Exit", None)) #endif // QT_CONFIG(tooltip) - self.btnAddonManager.setProperty("qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnAbout.setToolTip(QCoreApplication.translate("winMain", u"About", None)) + self.btnOptions.setToolTip(QCoreApplication.translate("mainWindow", u"Settings", None)) #endif // QT_CONFIG(tooltip) - self.btnAbout.setProperty("qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + self.btnOptions.setProperty(u"qssClass", [ + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnMinimize.setToolTip(QCoreApplication.translate("winMain", u"Minimize", None)) + self.btnAddonManager.setToolTip(QCoreApplication.translate("mainWindow", u"Addon manager", None)) #endif // QT_CONFIG(tooltip) - self.btnMinimize.setProperty("qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + self.btnAddonManager.setProperty(u"qssClass", [ + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnExit.setToolTip(QCoreApplication.translate("winMain", u"Exit", None)) + self.btnAbout.setToolTip(QCoreApplication.translate("mainWindow", u"About", None)) #endif // QT_CONFIG(tooltip) - self.btnExit.setProperty("qssClass", [ - QCoreApplication.translate("winMain", u"icon-lg", None)]) + self.btnAbout.setProperty(u"qssClass", [ + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.lblWorld.setToolTip(QCoreApplication.translate("winMain", u"Game server", None)) + self.btnMinimize.setToolTip(QCoreApplication.translate("mainWindow", u"Minimize", None)) #endif // QT_CONFIG(tooltip) - self.lblWorld.setText(QCoreApplication.translate("winMain", u"World", None)) + self.btnMinimize.setProperty(u"qssClass", [ + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.cboWorld.setToolTip(QCoreApplication.translate("winMain", u"Select game server", None)) + self.btnExit.setToolTip(QCoreApplication.translate("mainWindow", u"Exit", None)) #endif // QT_CONFIG(tooltip) + self.btnExit.setProperty(u"qssClass", [ + QCoreApplication.translate("mainWindow", u"icon-lg", None)]) #if QT_CONFIG(tooltip) - self.btnSwitchGame.setToolTip(QCoreApplication.translate("winMain", u"Switch game", None)) + self.lblWorld.setToolTip(QCoreApplication.translate("mainWindow", u"Game server", None)) #endif // QT_CONFIG(tooltip) - self.btnSwitchGame.setProperty("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)) + self.lblWorld.setText(QCoreApplication.translate("mainWindow", u"World", None)) #if QT_CONFIG(tooltip) - self.btnLogin.setToolTip(QCoreApplication.translate("winMain", u"Start your adventure!", None)) + self.cboWorld.setToolTip(QCoreApplication.translate("mainWindow", u"Select game server", None)) #endif // QT_CONFIG(tooltip) - self.btnLogin.setText(QCoreApplication.translate("winMain", u"Play", None)) - self.btnLogin.setProperty("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)]) #if QT_CONFIG(tooltip) - self.chkSaveAccount.setToolTip(QCoreApplication.translate("winMain", u"Save last used world and account name", None)) + self.btnSwitchGame.setToolTip(QCoreApplication.translate("mainWindow", u"Switch game", None)) #endif // QT_CONFIG(tooltip) - self.chkSaveAccount.setText(QCoreApplication.translate("winMain", u"Remember account", None)) + self.btnSwitchGame.setProperty(u"qssClass", [ + 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.chkSavePassword.setToolTip(QCoreApplication.translate("winMain", u"Save last used password", None)) + self.btnStartGame.setToolTip(QCoreApplication.translate("mainWindow", u"Start your adventure!", None)) #endif // QT_CONFIG(tooltip) - self.chkSavePassword.setText(QCoreApplication.translate("winMain", u"Remember password", None)) + self.btnStartGame.setText(QCoreApplication.translate("mainWindow", u"Play", None)) + self.btnStartGame.setProperty(u"qssClass", [ + 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("mainWindow", u"Save last used world and account name", None)) +#endif // QT_CONFIG(tooltip) + self.chkSaveAccount.setText(QCoreApplication.translate("mainWindow", u"Remember account", None)) +#if QT_CONFIG(tooltip) + self.chkSavePassword.setToolTip(QCoreApplication.translate("mainWindow", u"Save last used password", None)) +#endif // QT_CONFIG(tooltip) + self.chkSavePassword.setText(QCoreApplication.translate("mainWindow", u"Remember password", None)) pass # retranslateUi diff --git a/src/onelauncher/ui/patch_game_window.py b/src/onelauncher/ui/patch_game_window.py new file mode 100644 index 00000000..d7a6fa9d --- /dev/null +++ b/src/onelauncher/ui/patch_game_window.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +########################################################################### +# Patching window 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 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.patch_game import ( + PATCH_CLIENT_RUNNER, + patch_game, +) +from onelauncher.patch_game import logger as patch_game_logger +from onelauncher.utilities import Progress + +from .patch_game_window_uic import Ui_patchGameWindow +from .qtapp import get_qapp +from .utilities import log_record_to_rich_text + +logger = logging.getLogger(__name__) + + +class PatchGameWindow(QtWidgets.QDialog): + def __init__( + self, + game_id: GameConfigID, + config_manager: ConfigManager, + patch_server_url: str, + ): + super().__init__( + get_qapp().activeWindow(), + QtCore.Qt.WindowType.FramelessWindowHint, + ) + self.game_id = game_id + self.config_manager = config_manager + self.patch_server_url = patch_server_url + + self.progress: Progress | None = None + + self.ui = Ui_patchGameWindow() + self.ui.setupUi(self) + self.setWindowTitle("Patching Output") + + 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) + patch_game_logger.addHandler(self.ui_logs_handler) + + self.patching_finished = True + + def setup_ui(self) -> None: + self.finished.connect(self.cleanup) + + 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() + + if not PATCH_CLIENT_RUNNER.exists(): + logger.error("Cannot patch. run_ptch_client.exe is missing.") + self.ui.btnStart.setEnabled(False) + + 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 cleanup(self) -> None: + patch_game_logger.removeHandler(self.ui_logs_handler) + self.ui_logs_handler.close() + + self.nursery.cancel_scope.cancel() + + @override + def closeEvent(self, event: QtGui.QCloseEvent) -> None: + self.cleanup() + event.accept() + + def reset_buttons(self) -> None: + self.patching_finished = True + self.ui.btnStop.setText("Close") + self.ui.btnStart.setEnabled(True) + + self.progress = None + # Make sure it's not showing a busy indicator + self.ui.progressBar.setMinimum(1) + self.ui.progressBar.setMaximum(1) + self.ui.progressBar.reset() + + def btnStopClicked(self) -> None: + if self.patching_finished: + self.close() + else: + self.patching_cancel_scope.cancel() + logger.info("*** Aborted ***") + + async def keep_progress_bar_updated(self) -> None: + # Will be canceled once the patching 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 start(self) -> None: + self.patching_finished = False + self.ui.btnStart.setEnabled(False) + self.ui.btnStop.setText("Abort") + + self.progress = Progress() + + logger.info("*** Started ***") + with trio.CancelScope() as self.patching_cancel_scope: + await patch_game( + patch_server_url=self.patch_server_url, + game_id=self.game_id, + config_manager=self.config_manager, + progress=self.progress, + ) + logger.info("*** Finished ***") + + self.reset_buttons() + # Let user know that patching is finished if the window isn't currently + # focussed. + self.activateWindow() 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/patching_window_uic.py b/src/onelauncher/ui/patch_game_window_uic.py similarity index 61% rename from src/onelauncher/ui/patching_window_uic.py rename to src/onelauncher/ui/patch_game_window_uic.py index d5bb5969..1ffb091a 100644 --- a/src/onelauncher/ui/patching_window_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/patching_window.ui b/src/onelauncher/ui/patching_window.ui deleted file mode 100644 index 056f8aea..00000000 --- a/src/onelauncher/ui/patching_window.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/qtapp.py b/src/onelauncher/ui/qtapp.py similarity index 88% rename from src/onelauncher/qtapp.py rename to src/onelauncher/ui/qtapp.py index cf2e0eeb..77b90081 100644 --- a/src/onelauncher/qtapp.py +++ b/src/onelauncher/ui/qtapp.py @@ -33,15 +33,15 @@ 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 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 @@ -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/select_subscription.ui b/src/onelauncher/ui/select_subscription.ui deleted file mode 100644 index d5d8bfc7..00000000 --- a/src/onelauncher/ui/select_subscription.ui +++ /dev/null @@ -1,88 +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 - - - - - - - - - - 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 52% rename from src/onelauncher/ui/select_subscription_uic.py rename to src/onelauncher/ui/select_subscription_window_uic.py index b1217c4d..6d157040 100644 --- a/src/onelauncher/ui/select_subscription_uic.py +++ b/src/onelauncher/ui/select_subscription_window_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! ################################################################################ @@ -19,45 +19,48 @@ 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) 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) - 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_window.ui similarity index 86% rename from src/onelauncher/ui/settings.ui rename to src/onelauncher/ui/settings_window.ui index f0a04a61..e9cbb075 100644 --- a/src/onelauncher/ui/settings.ui +++ b/src/onelauncher/ui/settings_window.ui @@ -1,7 +1,7 @@ - dlgSettings - + settingsWindow + Qt::WindowModality::ApplicationModal @@ -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 + @@ -124,12 +139,14 @@ - + - Select game directory from file system + Select game install directory from the file browser - - ... + + + icon-base + @@ -191,6 +208,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + gameLanguageComboBox + @@ -211,6 +231,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + highResCheckBox + @@ -240,6 +263,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + clientTypeComboBox + @@ -260,6 +286,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + standardLauncherLineEdit + @@ -285,26 +314,6 @@ - - - - Patch client DLL filename - - - Patch Client DLL - - - Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter - - - - - - - Patch client DLL filename - - - @@ -326,6 +335,9 @@ Settings Directory + + gameSettingsDirLineEdit + @@ -351,12 +363,14 @@ - + - Select settings folder from filesystem + Select game settings directory from the file browser - - ... + + + icon-base + @@ -384,6 +398,9 @@ Auto Manage Wine + + autoManageWineCheckBox + @@ -404,6 +421,9 @@ Wine Prefix + + winePrefixLineEdit + @@ -424,6 +444,9 @@ Wine Executable + + wineExecutableLineEdit + @@ -444,6 +467,9 @@ WINEDEBUG + + wineDebugLineEdit + @@ -496,6 +522,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + defaultLanguageComboBox + @@ -525,6 +554,9 @@ true + + defaultLanguageForUICheckBox + @@ -551,6 +583,9 @@ Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter + + gamesSortingModeComboBox + @@ -563,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 + + + + @@ -576,7 +640,7 @@ - + @@ -589,7 +653,7 @@ - + Qt::Orientation::Vertical @@ -623,7 +687,7 @@ - <html><head/><body><p>Enable advanced options</p></body></html> + Enable advanced options Advanced Options @@ -692,13 +756,18 @@ QLabel
.custom_widgets
+ + NoOddSizesQToolButton + QToolButton +
.custom_widgets
+
settingsButtonBox rejected() - dlgSettings + settingsWindow reject() diff --git a/src/onelauncher/ui/settings_uic.py b/src/onelauncher/ui/settings_window_uic.py similarity index 72% rename from src/onelauncher/ui/settings_uic.py rename to src/onelauncher/ui/settings_window_uic.py index f5560ee0..b6606afe 100644 --- a/src/onelauncher/ui/settings_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.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,28 +22,28 @@ QStackedWidget, QTabBar, QToolButton, QVBoxLayout, QWidget) -from .custom_widgets import FramelessQDialogWithStylePreview, FixedWordWrapQLabel +from .custom_widgets import (FixedWordWrapQLabel, FramelessQDialogWithStylePreview, NoOddSizesQToolButton) 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") @@ -104,10 +104,10 @@ def setupUi(self, dlgSettings: 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) @@ -189,17 +189,6 @@ def setupUi(self, dlgSettings: 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) @@ -219,10 +208,10 @@ def setupUi(self, dlgSettings: 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) @@ -328,23 +317,37 @@ def setupUi(self, dlgSettings: 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) @@ -353,7 +356,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 +366,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) @@ -373,132 +376,153 @@ def setupUi(self, dlgSettings: FramelessQDialogWithStylePreview) -> None: self.verticalLayout.addLayout(self.horizontalLayout) - - self.retranslateUi(dlgSettings) - self.settingsButtonBox.rejected.connect(dlgSettings.reject) +#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.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(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 directory from file system", 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("dlgSettings", u"...", None)) + self.browseForGameDirButton.setProperty(u"qssClass", [ + QCoreApplication.translate("settingsWindow", u"icon-base", 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.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.patchClientLabel.setText(QCoreApplication.translate("dlgSettings", u"Patch Client DLL", None)) + self.gameSettingsDirLabel.setText(QCoreApplication.translate("settingsWindow", u"Settings Directory", None)) #if QT_CONFIG(tooltip) - self.patchClientLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Patch client DLL filename", 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.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.browseForGameSettingsDirButton.setToolTip(QCoreApplication.translate("settingsWindow", u"Select game settings directory from the file browser", None)) #endif // QT_CONFIG(tooltip) - self.gameSettingsDirLabel.setText(QCoreApplication.translate("dlgSettings", u"Settings Directory", 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.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.winePrefixLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE prefix", None)) #endif // QT_CONFIG(tooltip) + self.winePrefixLabel.setText(QCoreApplication.translate("settingsWindow", u"Wine Prefix", None)) #if QT_CONFIG(tooltip) - self.gameSettingsDirButton.setToolTip(QCoreApplication.translate("dlgSettings", u"Select settings folder from filesystem", None)) + self.winePrefixLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE prefix", None)) #endif // QT_CONFIG(tooltip) - self.gameSettingsDirButton.setText(QCoreApplication.translate("dlgSettings", u"...", None)) - self.autoManageWineLabel.setText(QCoreApplication.translate("dlgSettings", u"Auto Manage Wine", None)) #if QT_CONFIG(tooltip) - self.winePrefixLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE prefix", None)) + self.wineExecutableLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE executable", None)) #endif // QT_CONFIG(tooltip) - self.winePrefixLabel.setText(QCoreApplication.translate("dlgSettings", u"Wine Prefix", None)) + self.wineExecutableLabel.setText(QCoreApplication.translate("settingsWindow", u"Wine Executable", None)) #if QT_CONFIG(tooltip) - self.winePrefixLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE prefix", None)) + self.wineExecutableLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Path to WINE executable", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.wineExecutableLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE executable", None)) + self.wineDebugLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Value for the WINEDEBUG environment variable", None)) #endif // QT_CONFIG(tooltip) - self.wineExecutableLabel.setText(QCoreApplication.translate("dlgSettings", u"Wine Executable", None)) + self.wineDebugLabel.setText(QCoreApplication.translate("settingsWindow", u"WINEDEBUG", None)) #if QT_CONFIG(tooltip) - self.wineExecutableLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Path to WINE executable", None)) + self.wineDebugLineEdit.setToolTip(QCoreApplication.translate("settingsWindow", u"Value for the WINEDEBUG environment variable", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.wineDebugLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Value for the WINEDEBUG environment variable", None)) + self.defaultLanguageLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Default language to use for games", None)) #endif // QT_CONFIG(tooltip) - self.wineDebugLabel.setText(QCoreApplication.translate("dlgSettings", u"WINEDEBUG", None)) + self.defaultLanguageLabel.setText(QCoreApplication.translate("settingsWindow", u"Default Language", None)) #if QT_CONFIG(tooltip) - self.wineDebugLineEdit.setToolTip(QCoreApplication.translate("dlgSettings", u"Value for the WINEDEBUG environment variable", None)) + self.defaultLanguageComboBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Default language to use for games", None)) #endif // QT_CONFIG(tooltip) #if QT_CONFIG(tooltip) - self.defaultLanguageLabel.setToolTip(QCoreApplication.translate("dlgSettings", u"Default language to use for games", 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.defaultLanguageLabel.setText(QCoreApplication.translate("dlgSettings", u"Default Language", None)) + self.defaultLanguageForUILabel.setText(QCoreApplication.translate("settingsWindow", u"Always Use Default Language For UI", None)) #if QT_CONFIG(tooltip) - self.defaultLanguageComboBox.setToolTip(QCoreApplication.translate("dlgSettings", u"Default language to use for games", 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("settingsWindow", u"Games Sorting Mode", None)) #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.closeAfterStartingGameLabel.setToolTip(QCoreApplication.translate("settingsWindow", u"Close OneLauncher when a game is started", None)) #endif // QT_CONFIG(tooltip) - self.defaultLanguageForUILabel.setText(QCoreApplication.translate("dlgSettings", u"Always Use Default Language For UI", None)) + self.closeAfterStartingGameLabel.setText(QCoreApplication.translate("settingsWindow", u"Close After Starting Game", 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.closeAfterStartingGameCheckBox.setToolTip(QCoreApplication.translate("settingsWindow", u"Close OneLauncher when a game is started", 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.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 35017e63..00000000 --- a/src/onelauncher/ui/setup_wizard.ui +++ /dev/null @@ -1,303 +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 - - - - - - - - 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 - - - - - - - - - - - - - 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 - - - - - - - - Add Game - - - - - - - - - - Exisiting 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 68% rename from src/onelauncher/ui/setup_wizard_uic.py rename to src/onelauncher/ui/setup_wizard_window_uic.py index 01d74dbd..9070517d 100644 --- a/src/onelauncher/ui/setup_wizard_uic.py +++ b/src/onelauncher/ui/setup_wizard_window_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! ################################################################################ @@ -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) @@ -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) @@ -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) @@ -121,15 +121,20 @@ 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) - Wizard.setPage(1, self.gamesSelectionWizardPage) + setupWizardWindow.setPage(1, self.gamesSelectionWizardPage) self.dataDeletionWizardPage = QWizardPage() self.dataDeletionWizardPage.setObjectName(u"dataDeletionWizardPage") self.verticalLayout_2 = QVBoxLayout(self.dataDeletionWizardPage) @@ -139,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") @@ -161,59 +166,70 @@ 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) 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.gamesListWidget.setProperty("qssClass", [ - QCoreApplication.translate("Wizard", u"icon-xl", 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("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("setupWizardWindow", u"\u2193", None)) +#if QT_CONFIG(tooltip) + self.upPriorityButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Increase priority", None)) +#endif // QT_CONFIG(tooltip) + self.upPriorityButton.setText(QCoreApplication.translate("setupWizardWindow", u"\u2191", None)) +#if QT_CONFIG(tooltip) + self.addExistingGameButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Select an existing game directory from the file browser", None)) #endif // QT_CONFIG(tooltip) - self.downPriorityButton.setText(QCoreApplication.translate("Wizard", u"\u2193", None)) + self.addExistingGameButton.setText(QCoreApplication.translate("setupWizardWindow", u"Add Existing Game", None)) #if QT_CONFIG(tooltip) - self.upPriorityButton.setToolTip(QCoreApplication.translate("Wizard", u"Increase priority", None)) + self.installGameButton.setToolTip(QCoreApplication.translate("setupWizardWindow", u"Create a new game installation", 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.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.gamesDeletionStatusListView.setProperty("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)) + 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("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 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 79d21ecd..00000000 --- a/src/onelauncher/ui/start_game_window.py +++ /dev/null @@ -1,209 +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 -from datetime import UTC, datetime - -import attrs -from PySide6 import QtCore, QtWidgets - -from onelauncher.game_config import GameConfigID -from onelauncher.logs import ExternalProcessLogsFilter, 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, get_qprocess -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.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, - ) - ) - - # 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.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") - self.ui.btnQuit.setEnabled(True) - - def btnAbortClicked(self) -> None: - if self.game_finished: - self.close() - else: - self.aborted = True - if self.process: - self.process.kill() - - def btnQuitClicked(self) -> None: - if self.game_finished: - self.close() - # Close entire application - app_cancel_scope.cancel() - - def run_startup_scripts(self) -> None: - """Runs Python scripts from addons with one that is approved by user""" - 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...") - 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( - f"'{script.relative_path}' startup script does not exist" - ) - except SyntaxError as e: - logger.exception(f"'{script.relative_path}' ran into syntax error: {e}") - - 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() - } - logger.info("Game started") - - self.exec() diff --git a/src/onelauncher/ui_utilities.py b/src/onelauncher/ui/utilities.py similarity index 98% rename from src/onelauncher/ui_utilities.py rename to 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) diff --git a/src/onelauncher/utilities.py b/src/onelauncher/utilities.py index 10068c84..cabe06bc 100644 --- a/src/onelauncher/utilities.py +++ b/src/onelauncher/utilities.py @@ -31,10 +31,12 @@ 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 from defusedxml import ElementTree # type: ignore[import-untyped] from typing_extensions import override @@ -44,6 +46,11 @@ 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 @@ -56,21 +63,22 @@ 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 = ( - 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: 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) + raise RelativePathError() + path = cls._get_real_path_from_fully_case_insensitive_path(normal_path) return super().__new__(cls, path) @@ -80,15 +88,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 ignore 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 +104,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 @@ -174,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""" diff --git a/src/onelauncher/v1x_config_migrator.py b/src/onelauncher/v1x_config_migrator.py index 523ca35e..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 @@ -46,7 +47,7 @@ class V1xGameAccounts: @attrs.frozen -class V1xStatupScripts: +class V1xStartupScripts: startup_scripts: tuple[StartupScript, ...] = () @@ -63,7 +64,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 +168,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) @@ -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) diff --git a/src/onelauncher/wine_environment.py b/src/onelauncher/wine_environment.py index a5ce8057..86f8d52a 100644 --- a/src/onelauncher/wine_environment.py +++ b/src/onelauncher/wine_environment.py @@ -30,10 +30,14 @@ 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 types import MappingProxyType +from typing import Final from urllib import request from urllib.error import HTTPError, URLError @@ -41,21 +45,33 @@ import certifi from PySide6 import QtCore, QtWidgets -from onelauncher.qtapp import get_qapp - from .config import platform_dirs -from .ui_utilities import show_warning_message +from .ui.qtapp import get_qapp +from .ui.utilities import show_warning_message from .wine.config import WineConfigSection 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.8/wine-10.8-staging-tkg-amd64.tar.xz" + +if sys.platform == "darwin": + 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.6.1/dxvk-2.6.1.tar.gz" + "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 class WineEnvironment: @@ -69,9 +85,27 @@ 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] = ( + 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_sikarugir_frameworks_path: Final[Path] = ( + self.downloads_path / f"frameworks-{SIKARUGIR_FRAMEWORKS_VERSION}" + ) + self._dlgDownloader: QtWidgets.QProgressDialog | None = None @property @@ -98,64 +132,6 @@ def create_progress_dialog(self) -> QtWidgets.QProgressDialog: dialog.setCancelButton(None) return dialog - def wine_setup(self) -> None: - """Sets wine program and downloads wine if it is not there or a new version is needed""" - - # 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" - 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 - - self.dlgDownloader.reset() - self.dlgDownloader.setLabelText("Extracting Wine...") - self.dlgDownloader.setValue(99) - self._wine_extractor(latest_wine_path_tar) - self.dlgDownloader.setValue(100) - - self.wine_path = ( - platform_dirs.user_data_path / f"wine/wine-{self.latest_wine_version}" - ) / "bin/wine" - - 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" - ).is_symlink(): - self._dxvk_injector() - 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) - - 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""" try: @@ -166,8 +142,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.", @@ -180,83 +156,28 @@ 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("")) - - # Extracts tar.xz file - with lzma.open(path) as file, tarfile.open(fileobj=file) as tar: - tar.extractall(path_no_suffix, 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) - - # Removes downloaded tar.xz - path.unlink() - - # Removes 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 - ): - rmtree(folder) - - def _dxvk_extracor(self, path: Path) -> None: - path_no_suffix = path.parent / (path.with_suffix("").with_suffix("")) - - # 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" - ) - - # 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() - - # Removes downloaded tar.gz - path.unlink() + def wine_setup(self) -> None: + """Sets wine program and downloads wine if it is not there or a new version is needed""" - # Removes 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 - ): - rmtree(folder) + # Uncomment line below when using Proton + # self.proton_documents_symlinker() # noqa: ERA001 - 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 - ) + if self.wine_binary_path.exists(): + return - dll_list = ["dxgi.dll", "d3d10core.dll", "d3d11.dll", "d3d9.dll"] + self.dlgDownloader.setLabelText("Downloading WINE...") - 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 + with TemporaryDirectory() as temp_dir_name: + download_path = Path(temp_dir_name) / "wine.tar.xz" - # Removes current dlls - (system32_dll).unlink(missing_ok=True) - (syswow64_dll).unlink(missing_ok=True) + if not self._downloader(WINE_URL, download_path): + return - # 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) + self.dlgDownloader.reset() + self.dlgDownloader.setLabelText("Extracting WINE...") + self.dlgDownloader.setValue(99) + self._wine_extractor(download_path) + self.dlgDownloader.setValue(100) def proton_documents_symlinker(self) -> None: """ @@ -282,13 +203,129 @@ def proton_documents_symlinker(self) -> None: prefix_documents_folder, target_is_directory=True ) + def _wine_extractor(self, archive_path: Path) -> None: + with TemporaryDirectory() as temp_dir_name: + temp_dir = Path(temp_dir_name) + + # Extract 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("*/")) + # Using `shutil.move` instead of `Path.rename`, so that it works across + # filesystems. + move(source_dir, self.latest_wine_path) + + # Remove old WINE versions. + for folder in self.downloads_path.glob("*/"): + 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_extractor(download_path) + self.dlgDownloader.setValue(100) + + self._dxvk_injector() + + def _dxvk_extractor(self, archive_path: Path) -> None: + with TemporaryDirectory() as temp_dir_name: + temp_dir = Path(temp_dir_name) + + # Extract the tar.gz archive. + with tarfile.open(archive_path, "r:gz") as file: + file.extractall(temp_dir, filter="data") + + 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) + + # Remove old DXVK versions. + for folder in self.downloads_path.glob("*/"): + if folder.name.startswith("dxvk") and folder != self.latest_dxvk_path: + rmtree(folder) + + def _dxvk_injector(self) -> None: + """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) + + # 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 sikarugir_frameworks_setup(self) -> None: + if self.latest_sikarugir_frameworks_path.exists(): + return + + self.dlgDownloader.setLabelText("Downloading WINE dependencies...") + + with TemporaryDirectory() as temp_dir_name: + download_path = Path(temp_dir_name) / "sikarugir_frameworks.tar.xz" + + if not self._downloader(SIKARUGIR_FRAMEWORKS_URL, download_path): + return + + self.dlgDownloader.reset() + self.dlgDownloader.setLabelText("Extracting WINE dependencies...") + self.dlgDownloader.setValue(99) + self._sikarugir_frameworks_extractor(download_path) + self.dlgDownloader.setValue(100) + + def _sikarugir_frameworks_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("*/")) / "Contents" / "Frameworks" + # Using `shutil.move` instead of `Path.rename`, so that it works across + # filesystems. + move(source_dir, self.latest_sikarugir_frameworks_path) + + # Remove old versions. + for folder in self.downloads_path.glob("*/"): + if ( + folder.name.startswith("frameworks") + and folder != self.latest_sikarugir_frameworks_path + ): + rmtree(folder) + def setup_files(self) -> None: - (platform_dirs.user_data_path / "wine").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.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.wine_setup() self.dlgDownloader.reset() - self.dxvk_setup() + if sys.platform == "darwin": + self.sikarugir_frameworks_setup() + else: + self.dxvk_setup() self.dlgDownloader.close() self.is_setup = True @@ -296,58 +333,83 @@ def setup_files(self) -> None: wine_management = WineManagement() -ESYNC_MINIMUM_OPEN_FILE_LMIT = 524288 +ESYNC_MINIMUM_OPEN_FILE_LIMIT = 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 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") - 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") - - # 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. + 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": + # 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_LIMIT: + edited_environment["WINEESYNC"] = "1" + + # Enable FSYNC. It overrides ESYNC and will only be used if + # the required kernel patches are installed. + 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" 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)) - - 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["WINEPREFIX"] = str(prefix_path) - # Move current program to arguments and replace it with WINE. - qprocess.setArguments([qprocess.program(), *qprocess.arguments()]) - qprocess.setProgram(str(wine_path)) + edited_environment["WINEDEBUG"] = wine_config.debug_level or "-all" + if "DXVK_LOG_LEVEL" not in edited_environment: + edited_environment["DXVK_LOG_LEVEL"] = "error" - qprocess.setProcessEnvironment(process_environment) + return (wine_path if wine_path else "", *command), MappingProxyType( + edited_environment + ) 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/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", ) diff --git a/tests/onelauncher/test_cli.py b/tests/onelauncher/test_cli.py new file mode 100644 index 00000000..664c972d --- /dev/null +++ b/tests/onelauncher/test_cli.py @@ -0,0 +1,137 @@ +from pathlib import Path +from shutil import rmtree + +import cyclopts +import pytest +from PySide6 import QtWidgets +from pytest_mock import MockerFixture + +from onelauncher import cli, main +from onelauncher.config_manager import ( + PROGRAM_CONFIG_DEFAULT_NAME, + ConfigFileError, + ConfigManager, +) + + +@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() + + main_window_mock = mocker.patch.object(main, "MainWindow", autospec=True) + + 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() + + mock = mocker.patch.object(main, "SetupWizard", autospec=True) + mock_instance = mock.return_value + mock_instance.result.return_value = QtWidgets.QDialog.DialogCode.Rejected + + await async_mock.call_args.kwargs["entry"]() + mock_instance.run.assert_called_once() + mock_instance.result.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() + + mocker.patch.object(QtWidgets.QMessageBox, "information") + mock = mocker.patch.object(main, "SetupWizard", autospec=True) + mock_instance = mock.return_value + 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.run.assert_called_once() + mock_instance.result.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() + + main_window_mock = mocker.patch.object(main, "MainWindow", autospec=True) + + await async_mock.call_args.kwargs["entry"]() + main_window_mock.assert_called_once() + + assert not backup_program_config.exists() 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() diff --git a/tests/onelauncher/test_utilities.py b/tests/onelauncher/test_utilities.py index 1d64118c..08717a1b 100644 --- a/tests/onelauncher/test_utilities.py +++ b/tests/onelauncher/test_utilities.py @@ -1,12 +1,12 @@ import logging -import os +import sys from pathlib import Path import pytest 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() @@ -35,8 +42,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-insensitive already by default, so there can only be one match.", ) def test_multiple_matches( self, tmp_path: Path, caplog: pytest.LogCaptureFixture @@ -59,8 +66,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-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 @@ -81,8 +88,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 permissions are needed to make symlinks on Windows.", ) def test_symlink(self, tmp_path: Path) -> None: folder = tmp_path / "folder" @@ -95,8 +102,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 permissions are needed to make symlinks on Windows.", ) def test_broken_symlink(self, tmp_path: Path) -> None: folder = tmp_path / "folder" @@ -157,7 +164,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 ced1ac72..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]] @@ -136,18 +135,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 +191,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.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +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/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]] name = "defusedxml" version = "0.7.1" @@ -213,6 +215,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.4" +source = { registry = "https://pypi.org/simple" } +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/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]] name = "elementpath" version = "5.0.4" @@ -280,16 +300,29 @@ 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" +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]] @@ -324,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]] @@ -357,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" }, @@ -368,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/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/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/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]] @@ -444,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]] @@ -473,13 +526,29 @@ wheels = [ [[package]] name = "nuitka" -version = "2.8.4" +version = "2.8.9" 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/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" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +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/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]] name = "onelauncher" @@ -492,6 +561,7 @@ dependencies = [ { name = "cachetools" }, { name = "cattrs", extra = ["tomlkit"] }, { name = "cryptography" }, + { name = "cyclopts" }, { name = "defusedxml" }, { name = "feedparser" }, { name = "httpx" }, @@ -503,36 +573,43 @@ dependencies = [ { name = "qtawesome" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, { name = "trio" }, - { name = "typer" }, { name = "xmlschema" }, { name = "zeep" }, ] [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" }, { name = "pyside6-essentials" }, { name = "pytest" }, + { name = "pytest-mock" }, { name = "pytest-randomly" }, + { 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" }, { name = "pytest" }, + { name = "pytest-mock" }, { name = "pytest-randomly" }, + { name = "pytest-trio" }, ] [package.metadata] @@ -543,6 +620,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,37 +632,44 @@ 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" }, ] [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" }, ] dev = [ + { name = "imageio", marker = "sys_platform == 'darwin'", specifier = ">=2.37.2" }, { name = "marko", specifier = ">=2.1.2" }, { name = "mypy" }, - { name = "mypy", specifier = ">=1.11.1" }, + { name = "mypy", specifier = ">=1.19.1" }, { 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.10" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, + { name = "typos", specifier = ">=1.40.0" }, ] lint = [ - { name = "mypy", specifier = ">=1.11.1" }, - { name = "pyside6-essentials", specifier = ">=6.9.0" }, - { name = "ruff", specifier = ">=0.11.11" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "pyside6-essentials", specifier = ">=6.10.0" }, + { name = "ruff", specifier = ">=0.14.10" }, { name = "types-cachetools", specifier = ">=5.3.0.7" }, + { name = "typos", specifier = ">=1.40.0" }, ] 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]] @@ -626,13 +711,25 @@ 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" +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]] @@ -646,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]] @@ -673,7 +770,7 @@ wheels = [ [[package]] name = "pyobjc" -version = "12.0" +version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyobjc-core" }, @@ -838,115 +935,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 +1051,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 +1145,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 +1201,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 +1282,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 +1311,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 +2182,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 +2210,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 +2475,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 +3034,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,37 +3062,37 @@ 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]] 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]] @@ -3014,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'" }, @@ -3023,9 +3120,21 @@ 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]] +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]] @@ -3040,6 +3149,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,43 +3246,56 @@ 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" -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]] @@ -3168,25 +3304,16 @@ 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" +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]] @@ -3218,7 +3345,7 @@ wheels = [ [[package]] name = "trio" -version = "0.31.0" +version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -3228,24 +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" } -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" } +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/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, + { 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]] @@ -3266,13 +3378,30 @@ 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" +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]]