diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..2f8df33 --- /dev/null +++ b/.clang-format @@ -0,0 +1,35 @@ +--- +Language: Cpp +BasedOnStyle: Google +Standard: c++20 + +# Google puts everything in <> that has no dot into the "C++ standard library" group, which +# would swallow every Qt header. Regroup explicitly: std, Qt, other libraries, then ours. +# The header a .cpp implements stays first (priority 0 is automatic). +IncludeBlocks: Regroup +IncludeCategories: + - Regex: '^<[a-z_0-9]+(\.h)?>$' + Priority: 1 + - Regex: '^ while Jammy preserves glibc 2.35. + sudo add-apt-repository -y ppa:ubuntu-toolchain-r/test sudo apt-get update - sudo apt-get install -y dpkg libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 patchelf ccache rpm - wget -q https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool - chmod +x /usr/local/bin/appimagetool + sudo apt-get install -y gcc-13 g++-13 ninja-build ccache dpkg patchelf rpm file libfuse2 xvfb libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev + + - name: Build QCoro against Qt + shell: bash + run: | + git clone --depth 1 --branch "$QCORO_REF" https://github.com/qcoro/qcoro.git "$RUNNER_TEMP/qcoro-src" + cmake -S "$RUNNER_TEMP/qcoro-src" -B "$RUNNER_TEMP/qcoro-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ + -DUSE_QT_VERSION=6 \ + -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ + -DQCORO_BUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/qcoro-build" + cmake --install "$RUNNER_TEMP/qcoro-build" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 + with: + key: release-linux-${{ matrix.workshop }} + + - name: Build injected JavaScript + run: just build-js - - name: Install Dependencies - run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} + - name: Configure + shell: bash + run: | + cmake --preset release \ + -DPX_WORKSHOP_VARIANT="$VARIANT" \ + -DPX_BUILD_TESTS=OFF \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" - - name: Build with Nuitka - run: just build-release-bundled --workshop ${{ matrix.workshop }} + - name: Build + run: cmake --build --preset release + + - name: Install into AppDir + run: cmake --install build/release --prefix "$PWD/dist/AppDir/usr" --strip - name: Verify glibc floor run: | set -euo pipefail - max=$(find dist/entrypoint.dist -type f \( -name '*.so*' -o -perm -u+x \) -exec objdump -T {} + 2>/dev/null \ + max=$(find dist/AppDir/usr -type f \( -name '*.so*' -o -perm -u+x \) -exec objdump -T {} + 2>/dev/null \ | grep -o 'GLIBC_[0-9.]*' | sort -uV | tail -1 || true) echo "Highest GLIBC symbol: ${max:-none}" test -n "$max" test "$(printf '%s\nGLIBC_2.35\n' "$max" | sort -V | tail -1)" = "GLIBC_2.35" - - name: Package tar.gz - run: tar -czf PxModRim-Linux-${{ matrix.workshop }}.tar.gz -C dist entrypoint.dist/ - - - name: Rename Linux packages + - name: Deploy Qt and build AppImage + shell: bash run: | set -euo pipefail - mv dist/PxModRim-x86_64.AppImage dist/PxModRim-Linux-x86_64-${{ matrix.workshop }}.AppImage - for f in dist/*.deb; do - [ -f "$f" ] || continue - mv "$f" "${f%.deb}-${{ matrix.workshop }}.deb" - done - for f in dist/*.rpm; do - [ -f "$f" ] || continue - mv "$f" "${f%.rpm}-${{ matrix.workshop }}.rpm" + tools="$RUNNER_TEMP/tools" + mkdir -p "$tools" + curl -fsSL -o "$tools/linuxdeploy" https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage + curl -fsSL -o "$tools/linuxdeploy-plugin-qt" https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage + chmod +x "$tools/linuxdeploy" "$tools/linuxdeploy-plugin-qt" + cp src/ui/assets/logo.svg dist/pxmodrim.svg + # Only SQLite is used; other drivers require external database clients. + find "$QT_ROOT_DIR/plugins/sqldrivers" -type f ! -name libqsqlite.so -delete + export PATH="$tools:$PATH" APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 + export QMAKE="$QT_ROOT_DIR/bin/qmake" QML_SOURCES_PATHS="$PWD/src/ui" + export OUTPUT="$PWD/dist/PxModRim-Linux-x86_64-$VARIANT.AppImage" + # Qt's OpenSSL backend dlopens libssl at run time and linuxdeploy only + # sees libcrypto, so a host libssl of another version would not pair + # with the bundled libcrypto and TLS would fail to initialize. + libdir=/lib/x86_64-linux-gnu + linuxdeploy --appdir dist/AppDir \ + --executable dist/AppDir/usr/bin/pxmodrim \ + --desktop-file dist/AppDir/usr/share/applications/pxmodrim.desktop \ + --icon-file dist/pxmodrim.svg \ + --library "$libdir/libssl.so.3" \ + --library "$libdir/libcrypto.so.3" \ + --plugin qt + keep="" + if [ "$VARIANT" = SteamWorkshop ]; then keep=keep-dialogs; fi + scripts/prune-qt-deploy.sh dist/AppDir/usr $keep + test -f dist/AppDir/usr/lib/libssl.so.3 + xvfb-run -a scripts/smoke-package.sh dist/AppDir/usr/bin/pxmodrim + linuxdeploy --appdir dist/AppDir --output appimage + tar -czf "dist/PxModRim-Linux-$VARIANT.tar.gz" -C dist/AppDir usr + + - name: Build deb and rpm + shell: bash + run: | + set -euo pipefail + if [ "$GITHUB_REF_TYPE" = tag ]; then + version="${GITHUB_REF_NAME#v}" + else + version=$(sed -n 's/^CMAKE_PROJECT_VERSION:STATIC=//p' build/release/CMakeCache.txt) + fi + + pkg=dist/deb + rm -rf "$pkg" + mkdir -p "$pkg/DEBIAN" "$pkg/opt/pxmodrim" "$pkg/usr/bin" \ + "$pkg/usr/share/applications" "$pkg/usr/share/icons/hicolor/scalable/apps" + cp -a dist/AppDir/usr/. "$pkg/opt/pxmodrim/" + ln -s /opt/pxmodrim/bin/pxmodrim "$pkg/usr/bin/pxmodrim" + install -m 644 packaging/linux/pxmodrim.desktop "$pkg/usr/share/applications/pxmodrim.desktop" + install -m 644 src/ui/assets/logo.svg "$pkg/usr/share/icons/hicolor/scalable/apps/pxmodrim.svg" + installed_size=$(du -sk "$pkg" | cut -f1) + cat > "$pkg/DEBIAN/control" <- ${{ secrets.MACOS_CERT != '' && secrets.MACOS_CERT_PASSWORD != '' && @@ -216,15 +379,12 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Setup Python - uses: actions/setup-python@main - with: - python-version-file: pyproject.toml - - - name: Install uv - uses: astral-sh/setup-uv@v7 + - name: Install Qt + uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4 with: - enable-cache: true + version: ${{ env.QT_VERSION }} + cache: true + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - name: Setup Node.js uses: actions/setup-node@v7 @@ -232,15 +392,67 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2.87.15 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: - tool: just@1.40.0 + tool: just + + - name: Install dependencies + run: brew install ninja ccache pugixml libzip spdlog dylibbundler + + - name: Build QCoro against Qt + shell: bash + run: | + git clone --depth 1 --branch "$QCORO_REF" https://github.com/qcoro/qcoro.git "$RUNNER_TEMP/qcoro-src" + cmake -S "$RUNNER_TEMP/qcoro-src" -B "$RUNNER_TEMP/qcoro-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ + -DUSE_QT_VERSION=6 \ + -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ + -DQCORO_BUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/qcoro-build" + cmake --install "$RUNNER_TEMP/qcoro-build" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 + with: + key: release-macos-${{ matrix.workshop }} + + - name: Build injected JavaScript + run: just build-js + + - name: Configure + shell: bash + run: | + cmake --preset release \ + -DPX_WORKSHOP_VARIANT="$VARIANT" \ + -DPX_BUILD_TESTS=OFF \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" - - name: Install Dependencies - run: uv sync --locked --dev ${{ matrix.workshop == 'NoWorkshop' && '--no-install-package pxsteamdl' || '--extra steam' }} + - name: Build + run: cmake --build --preset release - - name: Build with Nuitka - run: just build-release-bundled --workshop ${{ matrix.workshop }} + - name: Install and deploy Qt + shell: bash + run: | + set -euo pipefail + cmake --install build/release --prefix "$PWD/dist/stage" + mv dist/stage/pxmodrim.app dist/PxModRim.app + rm -rf dist/stage + # Only SQLite is used; other drivers require external database clients. + find "$QT_ROOT_DIR/plugins/sqldrivers" -type f ! -name libqsqlite.dylib -delete + macdeployqt dist/PxModRim.app -qmldir=src/ui -always-overwrite + keep="" + if [ "$VARIANT" = SteamWorkshop ]; then keep=keep-dialogs; fi + scripts/prune-qt-deploy.sh dist/PxModRim.app $keep + mkdir -p dist/PxModRim.app/Contents/Frameworks + # Preserve the Qt frameworks already copied by macdeployqt. + dylibbundler -of -b -x dist/PxModRim.app/Contents/MacOS/pxmodrim \ + -s "$PWD/dist/PxModRim.app/Contents/Frameworks" \ + -d dist/PxModRim.app/Contents/Frameworks/ -p @executable_path/../Frameworks/ + test -f dist/PxModRim.app/Contents/Frameworks/QtCore.framework/QtCore - name: Sign and notarize macOS app if: env.MACOS_SIGNING_CONFIGURED == 'true' @@ -293,11 +505,24 @@ jobs: security delete-keychain "$keychain_path" rm -f "$certificate" "$notary_zip" + - name: Ad-hoc sign and archive macOS app + if: env.MACOS_SIGNING_CONFIGURED != 'true' + run: | + set -euo pipefail + codesign --force --deep --sign - dist/PxModRim.app + ditto -c -k --keepParent dist/PxModRim.app dist/PxModRim-macOS.zip + hdiutil create -volname PxModRim \ + -srcfolder dist/PxModRim.app \ + -ov -format UDZO dist/PxModRim-macOS.dmg + + - name: Smoke test the packaged app + run: scripts/smoke-package.sh dist/PxModRim.app/Contents/MacOS/pxmodrim + - name: Rename macOS packages run: | set -euo pipefail - mv dist/PxModRim-macOS.zip dist/PxModRim-macOS-${{ matrix.workshop }}.zip - mv dist/PxModRim-macOS.dmg dist/PxModRim-macOS-${{ matrix.workshop }}.dmg + mv dist/PxModRim-macOS.zip "dist/PxModRim-macOS-$VARIANT.zip" + mv dist/PxModRim-macOS.dmg "dist/PxModRim-macOS-$VARIANT.dmg" - name: Upload artifact (dmg) uses: actions/upload-artifact@v7 @@ -310,6 +535,7 @@ jobs: with: name: PxModRim-macOS-ZIP-${{ matrix.workshop }} path: dist/PxModRim-macOS-${{ matrix.workshop }}.zip + release: needs: [build-linux, build-flatpak, build-windows, build-macos] runs-on: ubuntu-latest @@ -322,14 +548,8 @@ jobs: path: artifacts merge-multiple: false - - name: Package Windows release - run: | - for variant in NoWorkshop SteamWorkshop NativeWorkshop; do - 7z a "PxModRim-Windows-${variant}.zip" "./artifacts/PxModRim-Windows-${variant}/*" - done - - name: Create Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 with: files: | artifacts/PxModRim-Linux-*/*.tar.gz @@ -337,7 +557,7 @@ jobs: artifacts/PxModRim-Linux-Deb-*/*.deb artifacts/PxModRim-Linux-RPM-*/*.rpm artifacts/PxModRim-Flatpak-*/*.flatpak - PxModRim-Windows-*.zip + artifacts/PxModRim-Windows-*/*.zip artifacts/PxModRim-Windows-Installer-*/*-Setup.exe artifacts/PxModRim-macOS-DMG-*/*.dmg artifacts/PxModRim-macOS-ZIP-*/*.zip diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83409cf..9b65be1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,26 +8,35 @@ on: permissions: {} +env: + QT_VERSION: '6.10.3' + QCORO_REF: 'v0.13.0' + jobs: - ci: + build-and-test: + name: ${{ matrix.os }} / ${{ matrix.workshop }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + workshop: [NoWorkshop, SteamWorkshop, NativeWorkshop] + env: + QT_QPA_PLATFORM: offscreen steps: - uses: actions/checkout@v7 - - name: Setup Python - uses: actions/setup-python@main + - name: Install Qt + uses: jurplel/install-qt-action@a9c63c7c123f3069cff414e7e482d95dfa9d8125 # v4 with: - python-version-file: pyproject.toml + version: ${{ env.QT_VERSION }} + cache: true + modules: ${{ matrix.workshop == 'SteamWorkshop' && 'qtshadertools qtpositioning qtwebchannel qtwebengine' || 'qtshadertools' }} - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - enable-cache: true + - name: Setup MSVC environment + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1 - name: Setup Node.js uses: actions/setup-node@v7 @@ -35,29 +44,87 @@ jobs: node-version: 22 - name: Install just - uses: taiki-e/install-action@v2.87.16 + uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 with: - tool: just@1.40.0 + tool: just - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | sudo apt-get update - sudo apt-get install -y libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 + sudo apt-get install -y ninja-build ccache libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpugixml-dev libzip-dev zipcmp zipmerge ziptool libspdlog-dev - - name: Install Dependencies - run: uv sync --locked --dev --extra steam + - name: Install system dependencies (macOS) + if: runner.os == 'macOS' + run: brew install ninja ccache pugixml libzip spdlog - - name: Check (type checking + dependency layers) - run: just check + - name: Install system dependencies (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install ninja ccache --yes --no-progress + & "$env:VCPKG_INSTALLATION_ROOT\vcpkg.exe" install pugixml libzip spdlog --triplet x64-windows-static-md - - name: Run tests + - name: Build QCoro against Qt shell: bash run: | - if [ "$RUNNER_OS" = "Linux" ]; then - export QT_QPA_PLATFORM=offscreen + git clone --depth 1 --branch "$QCORO_REF" https://github.com/qcoro/qcoro.git "$RUNNER_TEMP/qcoro-src" + cmake -S "$RUNNER_TEMP/qcoro-src" -B "$RUNNER_TEMP/qcoro-build" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR" \ + -DCMAKE_INSTALL_PREFIX="$RUNNER_TEMP/qcoro" \ + -DUSE_QT_VERSION=6 \ + -DQCORO_WITH_QTDBUS=OFF \ + -DQCORO_WITH_QTWEBSOCKETS=OFF \ + -DQCORO_BUILD_EXAMPLES=OFF \ + -DBUILD_TESTING=OFF + cmake --build "$RUNNER_TEMP/qcoro-build" + cmake --install "$RUNNER_TEMP/qcoro-build" + + - name: Setup ccache + uses: hendrikmuhs/ccache-action@5ebbd400eff9e74630f759d94ddd7b6c26299639 # v1.2 + with: + key: ci-${{ matrix.os }}-${{ matrix.workshop }} + + - name: Build injected JavaScript + run: just build-js + + - name: Configure + shell: bash + run: | + extra=() + if [ "$RUNNER_OS" = "Windows" ]; then + # QCoro is built Release (/MD); a Debug build (/MDd) would not link. + extra+=(-DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="$VCPKG_INSTALLATION_ROOT/scripts/buildsystems/vcpkg.cmake" -DVCPKG_TARGET_TRIPLET=x64-windows-static-md) fi - just test + cmake --preset default \ + -DPX_WORKSHOP_VARIANT=${{ matrix.workshop }} \ + -DCMAKE_PREFIX_PATH="$QT_ROOT_DIR;$RUNNER_TEMP/qcoro" \ + "${extra[@]}" + + - name: Install clang tools (Linux SteamWorkshop) + if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' + run: | + # clang-tidy 18 from apt cannot parse libstdc++ 13's . + sudo apt-get install -y clang-tidy clang-tools + pipx install clang-format==22.1.8 + pipx install clang-tidy==22.1.8 + + - name: Check formatting (Linux SteamWorkshop) + if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' + run: just format-check + + - name: Lint (Linux SteamWorkshop) + if: runner.os == 'Linux' && matrix.workshop == 'SteamWorkshop' + # run-clang-tidy 18 probes the checks with `-list-checks -`, where + # clang-tidy 22 finds no .clang-tidy, so the checks are passed explicitly. + run: CLANG_TIDY="$(pipx environment --value PIPX_BIN_DIR)/clang-tidy" just lint -checks=-*,readability-identifier-naming + + - name: Build + run: cmake --build --preset default + + - name: Test + run: ctest --preset default workshop-backend: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b182c5f..fba1793 100644 --- a/.gitignore +++ b/.gitignore @@ -59,14 +59,12 @@ ltex.dictionary* _dummy_pxmodrim.py # Generated JavaScript (built from ts/ sources) -src/pxmodrim/ui/plugins/steam_workshop/inject.js +src/ui/plugins/steam_workshop/inject.js # OpenCode .opencode/** opencode.json -# Steam workshop plugin -src/pxmodrim/ui/plugins/steam_workshop/ # Local classification data dump installed-mod-categories.csv diff --git a/AGENTS.md b/AGENTS.md index 700f0be..a0b2132 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,115 +1,157 @@ # PxModRim — Agent guide ## Workflow -1. `just fix` — ruff lint fix + format (fastest cycle) -2. `just check` — ruff → `build-js` → pyright → check-deps -3. `just test` — run full test suite -4. `just ci` — `check` then `test` (matches CI pipeline) +1. `just format` — clang-format the C++ tree (fastest cycle) +2. `just check` — `format-check` → `lint` → `build` (`build` runs `build-js` first) +3. `just test` — build, then run the full ctest suite offscreen +4. `just ci` — `check` then `test` 5. Always make commits on the go — atomic, conventional commits as milestones are reached; never leave uncommitted work. +CI runs configure, build and test on every OS (see CI matrix). Only the Linux SteamWorkshop job also runs `just format-check` and `just lint`, so run `just check` before pushing. + ## Environment -**uv**, **Python 3.12.\*** only, **PySide6 >=6.11 + qasync**. -Task runner is **just** (`just` to list). CI uses `uv sync --locked --dev`. -`pxsteamdl` (Workshop downloader) is an optional dependency (`steam` extra, also in the `dev` group), built from source: needs CMake >= 3.24 and a C++20 compiler (GCC, Clang, or MSVC); external dependencies are vendored via CPM. Without it the app runs as a plain mod sorter. +- Task runner: **just** (`just` lists the recipes). Node.js 22 for `build-js` (`npx esbuild`). +- **C++23** compiler: GCC or Clang (CI also builds with MSVC on Windows). +- **CMake >= 3.24** with **Ninja** (the presets use it). **ccache** is picked up automatically when it is on `PATH`. +- **Qt 6** (`find_package(Qt6 6.8)` is the minimum; CI and releases use **6.10.3**: aqtinstall 3.3.0 cannot read the Windows 6.11 repository layout). Components: Core, Concurrent, Network, Sql, Gui, Quick, QuickControls2, QuickWidgets, Svg, Widgets, Test, Qml. `WebEngineQuick` is needed only by the SteamWorkshop variant. +- **QCoro 0.13** (`QCoro6`: Core, Network, Qml, Quick), built against the same Qt install. Enable coroutines with `qcoro_enable_coroutines()`, as the root CMakeLists already does. +- **pugixml**, **libzip**, **spdlog** via `find_package`. +- **CPM** (`cmake/CPM.cmake`) fetches glaze 5.5.4, Catch2 3.8.1 (tests) and PxSteamDL 0.3.0 (`cmake/Steam.cmake`, skipped by NoWorkshop). +- Without PxSteamDL the app runs as a plain mod sorter. ## Entrypoint -- `just run` (sets `LOGURU_LEVEL=DEBUG`) -- `uv run python -m pxmodrim` -- Chain: `__main__.py` -> `_app.py` (`App().run()`) -> DI composition root +- `just run` (Steam browser variant, `build/default`), `just run-native` (native Workshop browser, `build/native`) or `just run-none` (sorter only, `build/none`). Each configures its own build directory with the matching `PX_WORKSHOP_VARIANT`, builds, and starts the app with `PX_LOG_LEVEL=debug`; extra arguments are passed to the app. +- Chain: `src/app/main.cpp` `main()` → `runApp()`, the composition root: + config services → `CoreContext::create` → `px::modrim::registerCorePlugins` → `px::modrim::AppContext` → `px::modrim::registerUiPlugins` → `setupAll()` → `MainWindow` → `refreshMods()` → `initAll()`. +- `src/app/plugins.cpp` is the only list of concrete plugins. ## Module layout ``` -src/pxmodrim/ -├── _app.py # composition root: DI assembly, Fusion + QPalette -├── core/ # all domain logic; never imports ui/ -└── ui/ # Qt widgets + QML; imports core/ freely +src/ +├── core/ pxcore (static lib, no GUI): domain logic, services, downloads, workshop, organizer, plugin API +├── ui/ pxui (static lib) + QML module PxModRim: Qt Quick views, widgets, plugins/ +└── app/ pxmodrim executable: main.cpp (composition root), plugins.cpp (plugin list) +tests/ +├── core/ Catch2 → pxcore_tests (mirrors src/core/) +└── ui/ Catch2 + Qt Test → pxui_tests (shared QApplication in main.cpp) +packaging/ desktop file, icons, RPM spec, Flatpak manifest, NSIS script +scripts/ build-inject-js.py +legacy/ read-only Python reference of the previous implementation ``` +**Core must never know about UI.** `pxcore` links only non-GUI Qt modules (Core, Concurrent, Network, Sql) and must not include `ui/` headers. Check both with `grep -rn '#include "ui/' src/core tests/core` and `cmake --preset default --graphviz=build/graph.dot` (the target graph must show no `pxcore` → `Qt6::Gui`/`Quick`/`Widgets` edge). Plugins must not know about layers; layers must not know about plugins. + ## Download plugins -Downloads are plugins; the UI only talks to `core/downloads/manager.py` (`DownloadManager`, always present, plugin name `downloads`). -- Sources subclass `core.downloads.Downloader` (`core/downloads/steam.py` = `steam_downloader`, the only one today) and register with the manager in `setup()`. Third-party imports stay inside the source's own module; `_app.py` imports it lazily and skips it if disabled or missing. -- Catalog: `workshop_catalog` consumes the read-only catalog API with constructor-injected configuration/client factory; it resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `AppConfig.workshop_catalog_url` defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted config fields load from older files without a schema migration. -- UI plugins: `downloads_ui` (Downloads tab, source-agnostic, shown when any downloader exists), `steamworkshop` (Steam WebView, needs `steam_downloader`), `workshop_ui` (native QML Workshop browser, needs `workshop_catalog` and `downloads`). +Downloads are plugins; the UI only talks to `core/downloads/manager.hpp` (`DownloadManager`, always present, plugin name `downloads`, `kDownloadsManagerName`). +- Sources subclass `px::modrim::Downloader` (`core/downloads/downloader.hpp`). `SteamDownloader` (`core/downloads/steam.hpp`, plugin name `steam_downloader`) is the only one today. The constructor hands the source to the manager, and `setup()` registers it. Third-party includes stay inside the source's own `.cpp`, behind `PX_HAS_STEAM`. `registerCorePlugins` (`src/app/plugins.cpp`) adds it only when it is not disabled. +- Catalog: `WorkshopCatalog` (`core/workshop/catalog.hpp`, plugin `workshop_catalog`, depends on `downloads`) consumes the read-only catalog API through `core/workshop/client.hpp` with an injected configuration and client. It resolves transitive download plans and delegates files to `DownloadManager`, without activating mods. `CatalogSettings::url` (legacy `config.json` key `workshop_catalog_url`) defaults to `https://api.modrim.pyxiion.dev`; an empty string disables catalog network requests. Defaulted settings fields load from older files without a schema migration. +- UI plugins: `DownloadsUiPlugin` (`ui/plugins/downloads/`, Downloads tab, source-agnostic, shown when any downloader exists), `SteamWorkshopUiPlugin` (`ui/plugins/steam_workshop/`, Steam WebView, needs `steam_downloader`), `workshop::WorkshopUiPlugin` (`ui/plugins/workshop/`, native QML Workshop browser, needs `workshop_catalog` and `downloads`). - Disable at runtime: `PX_DISABLED_PLUGINS=steam_downloader,steamworkshop,downloads_ui,workshop_catalog,workshop_ui` (comma-separated). Disabling the catalog also omits its UI. -- Installed catalog state uses discovered mods' `published_file_id` (About/PublishedFileId.txt or numeric folder); only downloader-managed local copies have known sync times. Steam-client Workshop copies are installed, but cannot be marked outdated without a known sync timestamp. Downloading places mods in Local mods and never subscribes or activates them. -- Catalog requests batch installed metadata by 100 IDs and resolution by 50 mod roots / 10 collection roots; large Update all selections retain dependency-first order and deduplicate shared dependencies across requests. -- New source: subclass `Downloader`, register in `_app.py`, add its name to the `downloaders` list; UI needs no change. +- Installed catalog state uses discovered mods' `publishedFileId()` (About/PublishedFileId.txt or numeric folder); only downloader-managed local copies have known sync times. Steam-client Workshop copies are installed, but cannot be marked outdated without a known sync timestamp. Downloading places mods in Local mods and never subscribes or activates them. +- Catalog requests batch installed metadata by 100 IDs (`kIdsPerBatch`) and resolution by 50 mod roots / 10 collection roots (`kModIdsPerResolve`, `kCollectionIdsPerResolve`); large Update all selections retain dependency-first order and deduplicate shared dependencies across requests. +- New source: subclass `Downloader`, register it in `registerCorePlugins` (`src/app/plugins.cpp`), and add its name to the `downloaders` list; the UI needs no change. ## Plugin API -Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only `_app.py` (composition root) does. -- Settings: `PluginConfig(ctx.config_service, name, Struct, legacy={old_config_json_key: field})` in `setup()` → `plugins/.json`; subscribe to `.changed`. -- Settings UI: a UI plugin calls `app_ctx.add_settings_section(factory)` (one Settings tab each); subclass `ui.settings_section.PluginConfigSection` with `title` + `source` (QML whose root has `required property QtObject section`, reading `section.initial` and calling `section.set(key, value)`). Edits apply on Save only. -- Views: `RailView` subclasses on `app_ctx.add_rail_view`; `open_route(path)` handles `modrim:///…`; `lists_downloads = True` marks the download queue. -- `steam_downloader_ui` holds the Steam download settings section. -- Build variants: `packaging/build.py --workshop {NoWorkshop,SteamWorkshop,NativeWorkshop}` bakes `workshop-variant.txt` into the package, which `_app.py` turns into disabled plugins, and `_variant_exclusions` leaves the matching modules out of the Nuitka build. `NoWorkshop` omits PxSteamDL, the Downloads tab and both browsers (CI syncs it with `--no-install-package pxsteamdl`); `SteamWorkshop` omits the native browser; `NativeWorkshop` omits the web browser and WebEngine. CI builds all three on every OS. Build jobs set `UV_NO_SYNC=1` so `uv run` preserves the explicitly synced variant environment; both Workshop variants sync with `--extra steam`. +Core and the shell (`ui/window`, `ui/panels`) never name a concrete plugin; only `src/app/` does. +- Base classes: `px::modrim::Plugin` (`BasicPlugin`) and `px::modrim::UiPlugin` (`BasicPlugin`), from `core/plugin.hpp` and `ui/context.hpp`. Override `setup(Ctx&)` (synchronous, dependency order), `QCoro::Task init(Ctx&)` (awaited right after setup) and `shutdown()` (reverse order). Dependencies are passed to the constructor; the registry rejects unknown ones. `registerPlugin` with an existing name replaces the plugin in place. +- Settings: `PluginConfig(&ctx.configService(), "name", {{"old_config_key", "field"}})` in `setup()` → `plugins/.json`. Read `value()`; write with `update(T)`; subscribe to `changed`. The legacy map seeds the file the first time it is created. +- Settings UI: a UI plugin calls `app.addSettingsSection(factory)` (one Settings tab each). Subclass `PluginConfigSection` for a typed settings struct, or `PluginConfigSectionBase` (`ui/settings_section.hpp`) with `title()` and `source()`. The QML root has `required property QtObject section`, reads `section.initial` and calls `section.set(key, value)`. Edits apply on Save only. +- Views: `RailView` subclasses declare `static constexpr RailViewMeta kRailMeta{viewId, iconName, label}` (optionally `listsDownloads = true` for the download queue) and are registered with `app.addRailView()` in `registerUiPlugins`, before the plugins that read the rail. `openRoute(const QStringList&)` handles `modrim:///…`. +- `SteamDownloaderUiPlugin` (`ui/plugins/steam_downloader/`) holds the Steam download settings section. + +## Build variants +- `PX_WORKSHOP_VARIANT` (CMake cache, `NoWorkshop | SteamWorkshop | NativeWorkshop`, default `SteamWorkshop`): `cmake --preset default -DPX_WORKSHOP_VARIANT=NativeWorkshop`. +- Every build writes `workshop-variant.txt` beside the executable (build tree via a POST_BUILD copy; install via `install(FILES)`). `px::modrim::parseDisabledPlugins` (`src/app/plugins.cpp`) turns it into disabled plugins, on top of `PX_DISABLED_PLUGINS`. A missing file bakes nothing. +- `NoWorkshop` skips PxSteamDL (no `PX_HAS_STEAM`, no Steam downloader), omits the Downloads tab and both browsers, and never looks for WebEngine. +- `SteamWorkshop` adds the WebEngine browser and omits the native browser. +- `NativeWorkshop` omits the web browser and WebEngine. +- Code uses the compile-time gates, not variant names: `PX_HAS_STEAM` (public define of `pxcore` when PxSteamDL is found) and `PX_HAS_WEBENGINE` (public define of `pxui` when `Qt6::WebEngineQuick` is found, which only happens for SteamWorkshop). +- CI builds and tests all three variants on every OS; the release workflow packages all three. + +## Code style +- All code lives in `px::modrim`, with sub-namespaces such as `px::modrim::workshop` and `px::modrim::providers`. `px::core`, `px::ui` and `px::app` no longer exist. +- Headers are `.hpp` with `#pragma once`; sources are `.cpp`. Namespaces are `lower_case`. +- Names are enforced by `.clang-tidy` (`readability-identifier-naming`): types, enums and concepts `PascalCase`; functions, methods, variables and parameters `camelCase`; private and protected data members `m_camelCase`; public struct fields `camelCase`; constants and enumerators `kPascalCase`; macros `UPPER_CASE`. +- Wire structs (JSON through glaze) keep `snake_case` fields, because those fields are the JSON keys. Wrap them in `// NOLINTBEGIN(readability-identifier-naming)` and `// NOLINTEND(readability-identifier-naming)`. +- Names that a framework fixes keep the spelling it requires: coroutine awaiter methods (`await_ready`, `await_suspend`, `await_resume`), glaze metadata (`glz::meta` with `static constexpr auto value`), and Qt virtuals and macros. +- QML-facing `Q_INVOKABLE` and `Q_PROPERTY` names are camelCase. A QML signal handler is `on` plus the C++ signal name (`void changed();` is handled by `onChanged`), so the two spellings must match. +- `just format` applies `.clang-format` (Google base, 2-space indent, 80 columns, includes grouped as std, Qt, other libraries, project); `just format-check` verifies without rewriting. +- `just lint` runs `run-clang-tidy` over the `src` and `tests` sources in `build/default/compile_commands.json` (configuring first if needed); `just lint-fix` applies the renames. ## Key conventions -- `from __future__ import annotations` in every `.py` file +- C++23, `.clang-format` (Google base, 2-space indent, 80 columns; see Code style). The root sets `-Wall -Wextra -Wpedantic`. - No comments unless explaining _why_. _What_ comments allowed for long blocks. -- **Core must never know about UI.** Plugins must not know about layers. -- Async signal handlers need `@asyncSlot()` from `qasync` -- Never `QApplication.processEvents()`, `dialog.exec()`, `QThread`, `time.sleep()`, `QTimer.singleShot(0, ...)` -- use `await asyncio.to_thread()`, `await await_dialog()`, `await asyncio.sleep(0)` -- Never global singletons -- constructor DI everywhere -- Long blocking work -> `await asyncio.to_thread(target)` -- Git renames: `git mv`, never `shutil.move` -- Always ask user about Qt/QML widget type choice. -- Never orphan Qt objects -- always pass parent. -- Always make commits on the go -- never leave working tree uncommitted at milestones or task completion. +- Never global singletons — constructor DI everywhere. +- Never orphan Qt objects — always pass a parent. +- Errors: functions return `Result` (`std::expected`); use `std::unexpected(Error{...})`. +- Logging: spdlog. Log file `logs/pxmodrim.log` under the config directory; `PX_LOG_LEVEL` sets the level. +- Settings are JSON through glaze (`ConfigService`, `PluginConfig`): `config.json` and `plugins/.json` in the config directory. +- Git renames: `git mv`. +- Ask the user before picking a Qt/QML widget type. +- Always make commits on the go — never leave the working tree uncommitted at milestones or task completion. + +## Coroutines +- Async functions return `QCoro::Task`. Wait with `co_await`: + - network replies: `co_await qCoro(reply).waitForFinished()` + - signals: `co_await qCoro(obj, &Class::signal)` + - modal dialogs: `co_await awaitDialog(args...)` (`ui/components/dialogs.hpp`), never `exec()` + - delays: `co_await QCoro::sleepFor(duration)` + - QML-invokable coroutines return `QCoro::QmlTask` +- Long blocking work runs on a pool (`px::modrim::providers::runOnPool` or `QtConcurrent::run`), and the coroutine `co_await`s the future. +- Never `QApplication::processEvents()`, `QDialog::exec()`, `QThread`, `sleep`/`sleep_for`, or `QTimer::singleShot(0, …)`. Wait for the event that matters instead of spinning or deferring by zero. +- `QCoro::waitFor` belongs only at the top of `main()`. +- GCC 16 ICE (`gimple-low.cc`, `lower_stmt`) was hit by a coroutine with `co_await` in both arms of a `?:`; write it as `if`/`else` assigning a local. ## TypeScript / JS build -Steam Workshop WebView injection uses TypeScript sources in `src/pxmodrim/ui/plugins/steam_workshop/ts/`, bundled to `inject.js` via esbuild: +Steam Workshop WebView injection uses TypeScript sources in `src/ui/plugins/steam_workshop/ts/`, bundled to `src/ui/plugins/steam_workshop/inject.js` by `scripts/build-inject-js.py`, which runs: ``` -just build-js # npx esbuild ts/main.ts --bundle --format=iife --target=es2020 +npx --yes esbuild ts/main.ts --bundle --format=iife --outfile=inject.js --target=es2020 ``` -`build-js` runs as a prerequisite of `run`, `test`, `check`, and `build`. -Cached by mtime comparison -- only rebuilds when `ts/` sources change. -Output `inject.js` is gitignored; always build before running. - -## Layer dependency checker -`just check-deps` (`scripts/check-deps.py`) enforces strict import rules via pydeps. -Defines groups with explicit ALLOWED dependency matrices, including `core.workshop`. -If a new import violates boundaries, update `scripts/check-deps.py`'s `ALLOWED` dict. - -## CI matrix -- `ci.yml`: ubuntu-latest, windows-latest, macos-latest. `uv sync --locked --dev` -> `just check` -> `just test` -- Linux CI pre-installs: `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0`. Qt tests need `QT_QPA_PLATFORM=offscreen` -- `build.yml`: tag `v*` triggers Nuitka standalone + AppImage (Linux) / zip (Windows) - -## Packaging (Nuitka) -``` -just build # Nuitka standalone (debug) -just build-release # Nuitka + LTO + AppImage/zip -``` -Nuitka uses `--include-package-data=pxmodrim` -- generated `inject.js` is included automatically as long as it exists at build time (which `build-js` ensures). +- `just build-js` runs before `build`, `build-release`, `check` and therefore `test`. It is mtime-cached and rebuilds only when `ts/` changes. +- `inject.js` is gitignored. When it exists, `src/ui/CMakeLists.txt` embeds it as a resource (`plugins/*/inject.js` is globbed with `CONFIGURE_DEPENDS`). Without it the web browser remains usable but has no injected download controls. ## Testing -- Config: `--import-mode=importlib`, `--no-qt-log`, `pythonpath = 'src'`, `testpaths = ['tests']` -- Test tree mirrors `src/pxmodrim/` -- Single test: `uv run pytest tests/test_metadata/test_structures.py -v` -- Mock providers by subclassing `BaseModProvider` +- Catch2 3.8.1 (CPM). `tests/core` → `pxcore_tests`, `tests/ui` → `pxui_tests`. `catch_discover_tests` registers every case with ctest. +- `tests/ui/main.cpp` owns the single `QApplication` for the UI binary (offscreen). Qt allows one per process. +- Run everything: `just test` (`QT_QPA_PLATFORM=offscreen ctest --preset default`). One case: `build/default/tests/pxui_tests "case name"`, or `ctest --preset default -R `. +- The test tree mirrors `src/`. Fake sources subclass `Downloader` (see `FakeDownloader` in `tests/core/downloads/test_manager.cpp`); providers are mocked by subclassing `providers::BaseModProvider`. +- Tests that need a variant feature are wrapped in `#ifdef PX_HAS_STEAM` / `PX_HAS_WEBENGINE`, as `tests/core/downloads/test_steam.cpp` is. ## QML / SVG quirks -- Icons via `image://icons/?color=` -- `SvgIconProvider` on shared `QQmlEngine` -- **Color URL encoding**: QML must `encodeURIComponent(color)`. Provider does `urllib.parse.unquote`. -- `QQuickImageProvider.Pixmap` works at runtime; pyright flags false positive. -- QML files sit next to their Python panel +- QML files sit directly in `src/ui/` (the module root, URI `PxModRim`). Qt does not reliably register types from subfolders, and plain type names resolve only within one flat module. +- Resource paths: `:/qt/qml/PxModRim/theme/style.qss`, `:/qt/qml/PxModRim/assets/*`, `:/qt/qml/PxModRim/plugins//inject.*`. +- Icons come from `image://icons/?color=`, served by `SvgIconProvider` (`ui/components/svg_provider.hpp`) on the shared `QQmlEngine`. +- **Color URL encoding**: QML must `encodeURIComponent(color)`. The provider decodes with `QUrl::fromPercentEncoding`. +- `main.cpp` keeps `Q_IMPORT_QML_PLUGIN(PxModRimPlugin)` and sets `QT_QUICK_CONTROLS_STYLE=Basic` by default. ## Companion mods C# RimWorld mod submodules (see `companion-mods/AGENTS.md`): -- `companion-mods/PxLoadingProgress` -- Harmony-patching loading progress mod -- `companion-mods/rimworld-utils/` -- shared C# build infrastructure (from ilyvion/rimworld-utils) +- `companion-mods/PxLoadingProgress` — Harmony-patching loading progress mod +- `companion-mods/rimworld-utils/` — shared C# build infrastructure (from ilyvion/rimworld-utils) ## Other source trees -- `cf-workers/` -- Cloudflare Workers (Steam Workshop dep resolver, separate deploy) -- `rimsort-original/` -- vendored original RimSort reference (excluded from linting) +- `cf-workers/` — Cloudflare Workers (Steam Workshop dependency resolver, separate deploy); checked by the `workshop-backend` CI job. -## Stale / needs attention -- `core/loading.py` -- `LoadingState` QObject, planned move to `ui/progress.py`. Three files still import from `core/loading`. -- `core/models/view/` -- view models living in `core/`; potential future move to `ui/`. - -## For AI PRs +## CI matrix +- `ci.yml`: ubuntu-latest, windows-latest, macos-latest × NoWorkshop, SteamWorkshop, NativeWorkshop. Each job installs Qt 6.10.3 (`jurplel/install-qt-action`; `qtdeclarative` and `qtsvg` ship in the base package, so `modules` lists only `qtshadertools`, plus the WebEngine addons for SteamWorkshop), builds QCoro v0.13.0 from source against that Qt, runs `just build-js`, then `cmake --preset default` → `cmake --build --preset default` → `ctest --preset default`. +- Linux CI installs `libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` plus pugixml, libzip (with `zipcmp zipmerge ziptool`, which its CMake config references) and spdlog dev packages. Windows uses vcpkg triplet `x64-windows-static-md` and builds Release, because the prebuilt QCoro is `/MD`. +- The Linux SteamWorkshop job alone runs `just format-check` and `just lint` after configure. Both tools are pinned to 22.1.8 from PyPI (`pipx`): the apt clang-tidy 18 cannot parse libstdc++ 13's ``, and apt clang-format 18 formats differently. `just lint` reads a copy of `compile_commands.json` without Qt's GCC-only `-mno-direct-extern-access`; `CLANG_TIDY` overrides the binary. +- The Ubuntu runner's GCC 13 ICEs on braced-list or ternary arguments inside a `co_await` operand (`co_await f({x})`); hoist them into locals. libc++ (macOS) hard-errors on glaze's `<=>` probe of a `std::map` holding a `std::variant`, and clang rejects glaze reflection of types in an anonymous namespace; keep reflected structs in a named namespace. +- Qt tests need `QT_QPA_PLATFORM=offscreen`; CI sets it for every job. + +## Packaging +Release builds run on `v*` tags or manual dispatch (`build.yml`); only tag runs publish a GitHub release. Each variant runs `cmake --build --preset release` and `cmake --install`, then: +- **Linux**: Ubuntu 22.04 with GCC 13 from `ubuntu-toolchain-r/test` supplies C++23 while retaining the glibc 2.35 floor. Install into `dist/AppDir/usr`; linuxdeploy + linuxdeploy-plugin-qt write the AppImage and deploy Qt into the same tree; `PxModRim-Linux-.tar.gz` is `usr/`; `.deb` (`/opt/pxmodrim`, `/usr/bin/pxmodrim` symlink) and `.rpm` (`packaging/linux/pxmodrim.spec`) are built from the same tree. The Flatpak manifest (`packaging/flatpak/`) consumes the tarball. +- **Windows**: install into `dist/PxModRim/bin`, rename to `PxModRim.exe`, `windeployqt --release --no-translations --qmldir src/ui`, then build the NSIS installer (`packaging/windows/installer.nsi`) and release zip in the Windows job, including on manual dispatch. Invoke NSIS from PowerShell so Git Bash does not convert its `/D` options into paths. +- **macOS**: install as `pxmodrim.app`, rename to `PxModRim.app`, `macdeployqt -qmldir=src/ui`, `dylibbundler -of` for Homebrew dylibs while preserving the deployed Qt frameworks (`-od` would erase them). Developer ID signing and notarization run only when the secrets are set; otherwise the app is ad-hoc signed. +- Package versions come from the tag on tag runs and `CMAKE_PROJECT_VERSION` on manual dispatch; keep `project(VERSION)` in the root CMakeLists in step with release tags. +- Linux and macOS deploy only the SQLite SQL driver; other Qt SQL drivers require external database clients that the application does not use. +- Every package goes through `scripts/prune-qt-deploy.sh` after deployment, which drops what the UI never loads (non-Basic Quick Controls styles, Particles, Effects, Qt `.qm` catalogs; Dialogs too unless the variant is SteamWorkshop, because QtWebEngine's QML delegates import `QtQuick.Dialogs`), then `scripts/smoke-package.sh` starts the packaged app and fails on missing QML modules or libraries (Linux runs it under `xvfb-run`). A new QML import of a pruned module needs its entry removed from the prune script. +- The Linux bundle must ship `libssl.so.3` next to `libcrypto.so.3`: Qt's OpenSSL backend `dlopen`s libssl, and a host libssl of another version cannot pair with the bundled libcrypto (TLS then fails to initialize). `cmake --install --strip` strips the executable. + +## AI PRs PR description starts with: "Greetings, PyXiion! The silicon-based contributor is here." PR title starts with: "🦀" Omission = auto-rejected. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2614f..d53bdbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- Rewrote PxModRim in C++23 on Qt 6 (Qt Quick UI, QCoro coroutines) with CMake, replacing the Python/PySide6 implementation. User-visible behaviour is unchanged. Building from source now needs CMake 3.24+, Qt 6.8+ (CI uses 6.11), QCoro, pugixml, libzip and spdlog, see `AGENTS.md`; release packages are built from CMake install rules and Qt deployment tools. - Project license changed from LGPL-3.0 to GPL-3.0-or-later. Parts of earlier releases were derived from RimSort (GPL-3.0); `NOTICE` previously described RimSort as MIT-licensed, which was incorrect and is fixed. +- Re-implemented the RimSort-derived modules (XML conversion, game constants, About.xml metadata structures and parsing, startup-impact report parsing) independently of the original code, working only from the clean-room rewrite of their behaviour and the existing tests; their public interfaces are unchanged. +- Ported the Organizer rail view, folder dialogs, tree and filter models, tag editor, and auto-folder rule editor to the C++/Qt UI, retaining the existing QML behavior and adding Catch2 UI regression coverage. +- Pinned third-party CI and release actions to immutable commits without changing their selected versions. +- Removed unused Workshop injection imports and discarded callback results explicitly to satisfy static analysis. + +### Fixed +- Windows and macOS builds no longer fail on the renamed `Platform` enumerators; the UI-off configuration no longer builds UI tests; presets work with CMake 3.24; `just run*` launches the macOS app bundle; CI/release QCoro builds no longer require Qt WebSockets. +- Shutting down while a Workshop download is queued, logging in or running no longer leaves the catalog or Steam downloader coroutines touching freed objects. +- Concurrent Organizer folder moves can no longer create folder cycles; the move is validated against the committed tree. +- Auto-sort now resolves alternative package ids per dependency declaration instead of one shared substitute per missing id. +- Configuring the game path after a first run without one now initializes plugins (Organizer, Steam downloader) without a restart. +- The embedded Steam browser exposes its native bridge only to `steamcommunity.com`; deactivating a Workshop mod from it disables every installed copy; Organizer keyboard focus and range selection survive folder expansion. +- Mods with a parsed About.xml but no description no longer show the "invalid mod" text. +- Workshop backend: queue continuations are acknowledged only after they are re-queued, emptied collections drop their old members, picked collections honour version tags in the version filter, and failed search-index writes are retried by the crawler. +- Workshop backend validates picked-collection fields with the ES2022 own-property API. ## [0.3.1] - 2026-10-13 diff --git a/CLAUDE.md b/CLAUDE.md index bbb0dff..56d88cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,40 +6,35 @@ Full agent guide lives in [`AGENTS.md`](./AGENTS.md) — read it too, it has mor ## Commands -- `just fix` — ruff lint fix + format (fastest edit cycle) -- `just check` — ruff fix -> `build-js` -> pyright -> `check-deps` (matches CI's check step) -- `just test` — `build-js` + `dev-setup` -> `uv run pytest --doctest-modules --no-qt-log -s` -- `just ci` — `check` then `test` (full CI pipeline) -- `just run` — `build-js` + `dev-setup` -> `LOGURU_LEVEL=DEBUG uv run python -m pxmodrim` -- Single test: `uv run pytest tests/test_metadata/test_structures.py -v` -- `just bench ` — run `tests/benchmarks/bench_*.py` (name filters by substring, or omit for all) -- `just build-js` — bundles Steam Workshop TS injection (`ui/plugins/steam_workshop/ts/`) to `inject.js` via esbuild; gitignored, must build before running; mtime-cached +- `just format` — clang-format over `src/` and `tests/` (fastest edit cycle) +- `just check` — `format-check` → `build` → `build-js` +- `just test` — build, then `QT_QPA_PLATFORM=offscreen ctest --preset default` +- `just ci` — `check` then `test` +- `just configure` / `just build` / `just run` — configure the `default` preset, build it, run `build/default/src/app/pxmodrim` with `PX_LOG_LEVEL=debug` +- `just build-release` — `release` preset +- Single test case: `build/default/tests/pxui_tests "case name"` +- `just build-js` — bundles the Steam Workshop TS injection (`src/ui/plugins/steam_workshop/ts/`) to `inject.js` via esbuild; gitignored; mtime-cached ## Architecture -- Entry chain: `__main__.py` -> `_app.py` (`App().run()`, DI composition root, Fusion style + QPalette) -- `core/` — all domain logic, never imports `ui/` -- `ui/` — Qt widgets + QML, imports `core/` freely -- Layer boundaries are enforced by `scripts/check-deps.py` (`just check-deps`), which defines 23 groups with explicit allowed-dependency matrices via pydeps. A new cross-layer import that violates the rules must update that file's `ALLOWED` dict, not bypass the checker. -- Plugin system: plugins (e.g. `ui/plugins/steam_workshop/`) must not know about UI layers; layers must not know about plugins. -- QML files sit next to their Python panel (e.g. `ui/components/Header.qml` next to `header_panel.py`). -- Icons served via `image://icons/?color=` through `SvgIconProvider` on the shared `QQmlEngine`. QML must `encodeURIComponent(color)`; the provider does `urllib.parse.unquote`. -- Test tree mirrors `src/pxmodrim/`. Mock providers by subclassing `BaseModProvider`. -- `companion-mods/` — separate C# RimWorld mods (own `AGENTS.md`); `cf-workers/` — Cloudflare Workers deploy; `rimsort-original/` — vendored reference, excluded from lint/type-check. +- Entry chain: `src/app/main.cpp` (`runApp()`, DI composition root) → `src/app/plugins.cpp` (the only list of concrete plugins) +- `src/core/` (`pxcore`) — all domain logic, no GUI dependency, never includes `ui/` +- `src/ui/` (`pxui` + QML module `PxModRim`) — Qt Quick views, widgets, plugins; uses `core/` freely +- Plugins live under `src/ui/plugins/` and `src/core/downloads|workshop/`; they must not depend on the shell, and the shell must not name them +- QML files sit directly in `src/ui/` (flat module root); icons come from `image://icons/?color=`, with QML calling `encodeURIComponent(color)` +- `companion-mods/` — separate C# RimWorld mods (own `AGENTS.md`); `cf-workers/` — Cloudflare Workers deploy; `legacy/` — read-only Python reference ## Conventions -- `from __future__ import annotations` in every `.py` file. - No comments unless explaining *why*; brief *what* comments allowed only for long blocks. -- Async signal handlers need `@asyncSlot()` from `qasync`. -- Never `QApplication.processEvents()`, `dialog.exec()`, `QThread`, `time.sleep()`, `QTimer.singleShot(0, ...)` — use `await asyncio.to_thread()`, `await await_dialog()`, `await asyncio.sleep(0)`. +- Coroutines: `QCoro::Task` with `co_await`; never `QApplication::processEvents()`, `QDialog::exec()`, `QThread`, `sleep`, or `QTimer::singleShot(0, ...)` — use `co_await awaitDialog(...)`, `co_await QCoro::sleepFor(...)`, or run blocking work on a pool and `co_await` it. +- Avoid structured bindings and `co_await` inside ternaries in coroutines (GCC ICE). - Never global singletons — constructor DI everywhere. -- Long blocking work -> `await asyncio.to_thread(target)`. -- Git renames: `git mv`, never `shutil.move`. - Never orphan Qt objects — always pass a parent. +- Git renames: `git mv`. - Ask the user before picking a Qt/QML widget type. +- Always make commits on the go. ## Known stale areas -- `core/loading.py`'s `LoadingState` QObject is planned to move to `ui/progress.py`; three files still import from `core/loading`. -- `core/models/view/` holds view models that may move to `ui/`. +- The Python-era phases in `ROADMAP.md` describe the old implementation; their commands are historical. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bac3ca3 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,69 @@ +cmake_minimum_required(VERSION 3.24) +project(PxModRim VERSION 0.3.1 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_AUTOMOC ON) + +option(PX_BUILD_TESTS "Build tests" ON) +option(PX_BUILD_UI "Build the Qt Quick UI and the executable" ON) +set(PX_WORKSHOP_VARIANT "SteamWorkshop" CACHE STRING + "NoWorkshop | SteamWorkshop | NativeWorkshop") +set_property(CACHE PX_WORKSHOP_VARIANT PROPERTY STRINGS + NoWorkshop SteamWorkshop NativeWorkshop) + +find_program(PX_CCACHE ccache) +if(PX_CCACHE) + set(CMAKE_CXX_COMPILER_LAUNCHER "${PX_CCACHE}") + message(STATUS "ccache: ${PX_CCACHE}") +endif() + +set(CPM_USE_LOCAL_PACKAGES ON) +set(CPM_SOURCE_CACHE "$ENV{HOME}/.cache/CPM" CACHE PATH "") +include(cmake/CPM.cmake) + +find_package(Qt6 6.8 REQUIRED COMPONENTS Core Concurrent Network Sql Gui Quick QuickControls2 QuickWidgets Svg Widgets Test Qml QmlPrivate) +qt_standard_project_setup(REQUIRES 6.8) +set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml") + +if(NOT PX_WORKSHOP_VARIANT STREQUAL "NativeWorkshop" AND NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") + find_package(Qt6 6.8 REQUIRED COMPONENTS WebEngineQuick) +endif() +find_package(QCoro6 REQUIRED COMPONENTS Core Network Qml Quick) +qcoro_enable_coroutines() + +# NoWorkshop ships without Steam downloads, so PxSteamDL is not fetched or linked. +if(NOT PX_WORKSHOP_VARIANT STREQUAL "NoWorkshop") + include(cmake/Steam.cmake OPTIONAL) +endif() + +if(NOT TARGET ZLIB::ZLIB) + find_package(ZLIB REQUIRED) +endif() +find_package(pugixml REQUIRED) +find_package(libzip REQUIRED) +find_package(spdlog REQUIRED) +CPMAddPackage("gh:stephenberry/glaze@5.5.4") +if(PX_BUILD_TESTS) + CPMAddPackage("gh:catchorg/Catch2@3.8.1") + list(APPEND CMAKE_MODULE_PATH "${Catch2_SOURCE_DIR}/extras") +endif() + +if(MSVC) + add_compile_options(/W4 /utf-8 /bigobj) +else() + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +add_subdirectory(src/core) +if(PX_BUILD_UI) + add_subdirectory(src/ui) + add_subdirectory(src/app) +endif() + +if(PX_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..ff3253f --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,28 @@ +{ + "version": 5, + "configurePresets": [ + { + "name": "default", + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "release", + "inherits": "default", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_INTERPROCEDURAL_OPTIMIZATION": "ON" } + } + ], + "buildPresets": [ + { "name": "default", "configurePreset": "default" }, + { "name": "release", "configurePreset": "release" } + ], + "testPresets": [ + { + "name": "default", + "configurePreset": "default", + "environment": { "QT_QPA_PLATFORM": "offscreen" }, + "output": { "outputOnFailure": true } + } + ] +} diff --git a/README.md b/README.md index 05244b0..078a93a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

- PxModRim + PxModRim

A friendly, modern mod manager for RimWorld. NOw with fast Steam downloading

- Python 3.12 + C++23 License: GPL-3.0 Codacy grade Platforms diff --git a/ROADMAP.md b/ROADMAP.md index 31b81c3..2128744 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,6 +6,8 @@ acceptance-criteria-driven plan behind it. It reflects the state of the reposito `PyXiion/PxModRim` GitHub tracker as observed on 2026-09-21. Re-verify issue/PR/milestone state before treating any of the numbers below as current. +> Historical note: the phases below were written for the Python/PySide6 implementation (`uv`, `just test` via pytest, Nuitka). The project now builds with CMake and `just`; see `AGENTS.md`. Commands quoted in the phase text are kept as they were written. + ## Current state snapshot (2026-09-21) - Project version: `0.1.0` (`pyproject.toml`). diff --git a/aqtinstall.log b/aqtinstall.log new file mode 100644 index 0000000..79f6b61 --- /dev/null +++ b/aqtinstall.log @@ -0,0 +1,167 @@ +2026-10-16 22:18:40,516 - aqt.helper - DEBUG - helper 140120143906688 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:43,058 - aqt.helper - DEBUG - helper 140120143906688 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtpdf/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:18:45,386 - aqt.helper - DEBUG - helper 140120143906688 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:18:48,852 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:50,047 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:51,208 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:52,616 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:18:59,079 - aqt.helper - DEBUG - helper 139951542945664 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:00,297 - aqt.main - ERROR - installer 139951542945664 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:19:00,947 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:02,005 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:03,164 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:04,204 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:06,006 - aqt.helper - DEBUG - helper 139921843177344 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:07,135 - aqt.main - ERROR - installer 139921843177344 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:19:07,754 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:08,822 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:09,910 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:10,930 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:11,998 - aqt.helper - DEBUG - helper 140087631488896 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:13,053 - aqt.main - ERROR - installer 140087631488896 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:19:16,758 - aqt.helper - DEBUG - helper 139698355627904 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:19,055 - aqt.helper - DEBUG - helper 139698355627904 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtpdf/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:19:21,206 - aqt.helper - DEBUG - helper 139698355627904 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6110/x86_64/Updates.xml.sha256 +2026-10-16 22:19:23,928 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:24,986 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:26,061 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:27,225 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:28,496 - aqt.helper - DEBUG - helper 140002283718528 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:29,643 - aqt.main - ERROR - installer 140002283718528 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:19:30,027 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:31,062 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:32,373 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:33,437 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:34,614 - aqt.helper - DEBUG - helper 140367646533504 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:35,748 - aqt.main - ERROR - installer 140367646533504 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:19:36,283 - aqt.helper - DEBUG - helper 140308995204992 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:39,112 - aqt.helper - DEBUG - helper 140308995204992 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtpdf/6110/clang_64/Updates.xml.sha256 +2026-10-16 22:19:41,116 - aqt.helper - DEBUG - helper 140308995204992 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtwebengine/6110/clang_64/Updates.xml.sha256 +2026-10-16 22:19:49,691 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:50,749 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:51,749 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:52,828 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:53,873 - aqt.helper - DEBUG - helper 140553987299200 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:54,888 - aqt.main - ERROR - installer 140553987299200 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:19:55,338 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:56,857 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:57,798 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:58,807 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:19:59,850 - aqt.helper - DEBUG - helper 139797143357312 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:01,022 - aqt.main - ERROR - installer 139797143357312 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:20:07,161 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:08,274 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:09,297 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:10,283 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:11,231 - aqt.helper - DEBUG - helper 140696147408768 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:12,625 - aqt.main - ERROR - installer 140696147408768 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:20:16,210 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:17,726 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:18,710 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:19,863 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:20,943 - aqt.helper - DEBUG - helper 140351640856448 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:22,017 - aqt.main - ERROR - installer 140351640856448 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:20:25,583 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:27,034 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:28,141 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:29,133 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:30,150 - aqt.helper - DEBUG - helper 139895792011136 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:31,146 - aqt.main - ERROR - installer 139895792011136 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:20:34,640 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:38,563 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:39,592 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:40,595 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:41,700 - aqt.helper - DEBUG - helper 140191232956288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:20:42,693 - aqt.main - ERROR - installer 140191232956288 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:21:55,531 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:21:56,749 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:21:57,848 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:21:58,926 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:00,000 - aqt.helper - DEBUG - helper 140108064188288 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:01,341 - aqt.main - ERROR - installer 140108064188288 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +2026-10-16 22:22:01,865 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:02,967 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:04,107 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:05,118 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:06,262 - aqt.helper - DEBUG - helper 140021660686208 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6110/qt6_6110/Updates.xml.sha256 +2026-10-16 22:22:07,224 - aqt.main - ERROR - installer 140021660686208 Failed to download checksum for the file 'Updates.xml' from mirrors '['https://download.qt.io'] +==============================Suggested follow-up:============================== +* Check your internet connection +* Consider modifying `requests.max_retries_to_retrieve_hash` in settings.ini +* Consider modifying `mirrors.trusted_mirrors` in settings.ini (see https://aqtinstall.readthedocs.io/en/stable/configuration.html#configuration) +* Please use 'aqt list-qt windows desktop' to show versions of Qt available. +* Please use 'aqt list-qt windows desktop --arch ' to list valid architectures. +2026-10-16 22:22:46,358 - aqt.helper - DEBUG - helper 139704412780416 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/qt6_6103/qt6_6103/Updates.xml.sha256 +2026-10-16 22:22:48,719 - aqt.helper - DEBUG - helper 139704412780416 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtpdf/6103/x86_64/Updates.xml.sha256 +2026-10-16 22:22:50,870 - aqt.helper - DEBUG - helper 139704412780416 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/linux_x64/extensions/qtwebengine/6103/x86_64/Updates.xml.sha256 +2026-10-16 22:22:58,472 - aqt.helper - DEBUG - helper 140289240401792 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/desktop/qt6_6103/qt6_6103/Updates.xml.sha256 +2026-10-16 22:23:00,592 - aqt.helper - DEBUG - helper 140289240401792 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/extensions/qtpdf/6103/msvc2022_64/Updates.xml.sha256 +2026-10-16 22:23:02,738 - aqt.helper - DEBUG - helper 140289240401792 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/windows_x86/extensions/qtwebengine/6103/msvc2022_64/Updates.xml.sha256 +2026-10-16 22:23:05,582 - aqt.helper - DEBUG - helper 140520884995968 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/desktop/qt6_6103/qt6_6103/Updates.xml.sha256 +2026-10-16 22:23:08,129 - aqt.helper - DEBUG - helper 140520884995968 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtpdf/6103/clang_64/Updates.xml.sha256 +2026-10-16 22:23:10,089 - aqt.helper - DEBUG - helper 140520884995968 Attempt to download checksum at https://download.qt.io/online/qtsdkrepository/mac_x64/extensions/qtwebengine/6103/clang_64/Updates.xml.sha256 diff --git a/cf-workers/indexer.ts b/cf-workers/indexer.ts index cdabe75..b3f5bae 100644 --- a/cf-workers/indexer.ts +++ b/cf-workers/indexer.ts @@ -43,7 +43,7 @@ async function refreshIsolating(env: Env, ids: string[], budget: { left: number if (budget.left <= 0) return false; budget.left--; try { - await refreshSteamItems(env, ids); + await refreshSteamItems(env, ids, true); return true; } catch (error) { if (!isPerFileFailure(error)) throw error; @@ -117,7 +117,7 @@ export async function indexTick(env: Env): Promise { state.indexed += result.items.length; const reachedSeen = state.since > 0 && result.times_updated.every(time => time * 1000 < state.since); const items = state.since === 0 ? result.items : result.items.filter(item => (item.updated_at ?? 0) * 1000 >= state.since); - await saveItems(env.DB, items); + await saveItems(env.DB, items, true); if (result.next_cursor && !reachedSeen) { state.cursor = result.next_cursor; await saveState(env.DB, state); diff --git a/cf-workers/picks.ts b/cf-workers/picks.ts index 84b9351..62c7ec0 100644 --- a/cf-workers/picks.ts +++ b/cf-workers/picks.ts @@ -35,7 +35,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { if (!fields.has(field)) throw new HttpError(400, `Unexpected field: ${field}`); } for (const field of ['title', 'author', 'description', 'member_ids']) { - if (!Object.prototype.hasOwnProperty.call(input, field)) { + if (!Object.hasOwn(input, field)) { throw new HttpError(400, `Missing required field: ${field}`); } } @@ -65,23 +65,21 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } let previewUrl: string | null = null; - if (Object.prototype.hasOwnProperty.call(input, 'preview_url')) { - if (input.preview_url !== null) { - if (typeof input.preview_url !== 'string' || input.preview_url.length > 2048) { - throw new HttpError(400, 'preview_url must be null or an HTTPS URL of at most 2048 characters'); - } - let url: URL; - try { - url = new URL(input.preview_url); - } catch { - throw new HttpError(400, 'preview_url must be a valid HTTPS URL'); - } - if (url.protocol !== 'https:') throw new HttpError(400, 'preview_url must use HTTPS'); - previewUrl = input.preview_url; + if (Object.hasOwn(input, 'preview_url') && input.preview_url !== null) { + if (typeof input.preview_url !== 'string' || input.preview_url.length > 2048) { + throw new HttpError(400, 'preview_url must be null or an HTTPS URL of at most 2048 characters'); + } + let url: URL; + try { + url = new URL(input.preview_url); + } catch { + throw new HttpError(400, 'preview_url must be a valid HTTPS URL'); } + if (url.protocol !== 'https:') throw new HttpError(400, 'preview_url must use HTTPS'); + previewUrl = input.preview_url; } const tags: string[] = []; - if (Object.prototype.hasOwnProperty.call(input, 'tags')) { + if (Object.hasOwn(input, 'tags')) { if (!Array.isArray(input.tags) || input.tags.length > 30) { throw new HttpError(400, 'tags must contain at most 30 strings of at most 100 characters'); } @@ -93,7 +91,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } } const versions: string[] = []; - if (Object.prototype.hasOwnProperty.call(input, 'supported_versions')) { + if (Object.hasOwn(input, 'supported_versions')) { if (!Array.isArray(input.supported_versions) || input.supported_versions.length > 20) { throw new HttpError(400, 'supported_versions must contain at most 20 versions in major.minor[.patch] form'); } @@ -106,7 +104,7 @@ export function parsePickInput(value: unknown): PickedCollectionInput { } } let featuredRank = 0; - if (Object.prototype.hasOwnProperty.call(input, 'featured_rank')) { + if (Object.hasOwn(input, 'featured_rank')) { if (typeof input.featured_rank !== 'number' || !Number.isInteger(input.featured_rank) || input.featured_rank < 0 || input.featured_rank > 1000000) { throw new HttpError(400, 'featured_rank must be an integer from 0 to 1000000'); @@ -170,8 +168,10 @@ export async function listPicks( bindings.push(options.tag); } if (options.version !== null) { - predicates.push('EXISTS (SELECT 1 FROM json_each(p.supported_versions) WHERE value = ?)'); + const taggedVersion = /^\d+\.\d+(?:\.\d+)?$/.test(options.version); + predicates.push(`(EXISTS (SELECT 1 FROM json_each(p.supported_versions) WHERE value = ?)${taggedVersion ? ' OR EXISTS (SELECT 1 FROM json_each(p.tags) WHERE value = ?)' : ''})`); bindings.push(options.version); + if (taggedVersion) bindings.push(options.version); } const where = predicates.length ? ` WHERE ${predicates.join(' AND ')}` : ''; const orders = { diff --git a/cf-workers/steam.ts b/cf-workers/steam.ts index 2cbd217..6545606 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -204,7 +204,14 @@ function normalized(file: SteamFile, authors: Map): CatalogItem }; } -export async function saveItems(db: D1Database, items: CatalogItem[]): Promise { +export async function saveItems(db: D1Database, items: CatalogItem[], requireIndex = false): Promise { + // Index first so failed crawl writes remain eligible for verification retries. + try { + await indexMods(db, items.filter((item): item is CatalogMod => item.kind === 'mod')); + } catch (error) { + if (requireIndex) throw error; + console.error('Search index update failed', error instanceof Error ? error.message : error); + } const now = Date.now(); let rowsWritten = 0; for (let offset = 0; offset < items.length; offset += API_BATCH_SIZE) { @@ -214,11 +221,9 @@ export async function saveItems(db: D1Database, items: CatalogItem[]): Promise total + result.meta.rows_written, 0); } if (items.length) console.log('[D1_WRITES]', JSON.stringify({ category: 'catalog_cache', rows_written: rowsWritten })); - await indexMods(db, items.filter((item): item is CatalogMod => item.kind === 'mod')).catch(error => - console.error('Search index update failed', error instanceof Error ? error.message : error)); } -export async function refreshSteamItems(env: Env, ids: string[]): Promise> { +export async function refreshSteamItems(env: Env, ids: string[], requireIndex = false): Promise> { const fetched = new Map(); const unavailable: string[] = []; for (let offset = 0; offset < ids.length; offset += API_BATCH_SIZE) { @@ -250,7 +255,7 @@ export async function refreshSteamItems(env: Env, ids: string[]): Promise env.DB.prepare( diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index f439c95..d54a278 100644 --- a/cf-workers/tests/catalog.test.ts +++ b/cf-workers/tests/catalog.test.ts @@ -3,8 +3,10 @@ import { readFile } from 'node:fs/promises'; import { resolve as resolvePath } from 'node:path'; import { after, before, test } from 'node:test'; import { Miniflare } from 'miniflare'; -import type { CatalogCollection, CatalogItem, CatalogMod, CatalogPage, SteamFile } from '../types'; +import type { CatalogCollection, CatalogItem, CatalogMod, CatalogPage, DependencyMessage, Env, SteamFile } from '../types'; import { indexMods } from '../search'; +import { indexTick } from '../indexer'; +import workerHandler from '../worker'; const creator = '76561198000000001'; function mod(id: string, title: string, children: string[] = [], versions = ['1.6']): SteamFile { @@ -217,6 +219,40 @@ test('picked search treats wildcard text literally, combines tag/version filters assert.equal((await (await request('/catalog/collections?source=picked&q=%25&version=1.5')).json() as CatalogPage).total, 0); }); +test('picked version filters include numeric tag declarations with correct totals and pagination', async () => { + const declarations = [ + { slug: 'version-filter-tagged', tags: ['version-filter', '1.6'], supported_versions: [] }, + { slug: 'version-filter-explicit', tags: ['version-filter', '1.6'], supported_versions: ['1.6'] }, + { slug: 'version-filter-patch', tags: ['version-filter', '1.6.1'], supported_versions: [] }, + { slug: 'version-filter-lookalike', tags: ['version-filter', 'v1.6', '1.6beta', '1.60'], supported_versions: [] }, + ]; + try { + for (const { slug, ...declaration } of declarations) { + await createPick(slug, ['10', '14'], slug, declaration); + } + const detail = await (await request('/catalog/collections/picked/version-filter-tagged')).json() as { collection: CatalogCollection }; + assert.deepEqual(detail.collection.supported_versions, ['1.6']); + const path = '/catalog/collections?source=picked&tag=version-filter&version=1.6&limit=1'; + const first = await (await request(path)).json() as CatalogPage; + assert.equal(first.total, 2); + assert.equal(first.items.length, 1); + assert.ok(first.next_cursor); + const second = await (await request(`${path}&cursor=${encodeURIComponent(first.next_cursor!)}`)).json() as CatalogPage; + assert.equal(second.total, 2); + assert.equal(second.next_cursor, null); + assert.deepEqual([...first.items, ...second.items].map(item => item.id).sort(), [ + 'picked:version-filter-explicit', 'picked:version-filter-tagged', + ]); + const patch = await (await request('/catalog/collections?source=picked&tag=version-filter&version=1.6.1')).json() as CatalogPage; + assert.equal(patch.total, 1); + assert.deepEqual(patch.items.map(item => item.id), ['picked:version-filter-patch']); + } finally { + for (const { slug } of declarations) { + await request(`/catalog/collections/picked/${slug}`, 'DELETE', undefined, 'test-admin'); + } + } +}); + test('picked ranks round-trip through PUT, detail and ordered lists when editing metadata', async () => { const slugs = ['rank-first', 'rank-second']; const body = { @@ -361,6 +397,86 @@ test('the idempotent dependency migration and existing /deps response preserve p assert.equal(value.items['777']?.package_id, 'existing.mod'); }); +test('an authoritative empty Steam collection clears stored and cached dependency membership', async () => { + const rootId = '80001'; + const memberId = '80002'; + files[rootId] = { ...mod(rootId, 'Emptied collection', [memberId]), file_type: 2, result: 15 }; + const now = Date.now(); + await db.batch([ + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind(rootId, 'Emptied collection', 1, 'OK', now, '', memberId), + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind(memberId, 'Removed member', 0, 'OK', now, '', ''), + ]); + try { + const retained = await (await request(`/deps?id=${rootId}&read=unavailable`)).json() as { items: Record }; + assert.deepEqual(retained.items[rootId]?.deps, [memberId]); + files[rootId] = { ...mod(rootId, 'Emptied collection'), file_type: 2 }; + for (let read = 0; read < 2; read++) { + const response = await request(`/deps?id=${rootId}&read=${read}`); + assert.equal(response.status, 200); + const value = await response.json() as { isComplete: boolean; items: Record }; + assert.equal(value.isComplete, true); + assert.deepEqual(value.items[rootId]?.deps, []); + assert.deepEqual(Object.keys(value.items), [rootId]); + } + const stored = await db.prepare('SELECT deps FROM items WHERE id = ?').bind(rootId).first<{ deps: string }>(); + assert.equal(stored?.deps, ''); + } finally { + delete files[rootId]; + await db.prepare('DELETE FROM items WHERE id IN (?, ?)').bind(rootId, memberId).run(); + } +}); + +test('queue continuations are acknowledged only after sending succeeds and retried on send failure', async (t) => { + const roots = ['80101', '80111']; + const detailCalls = new Map(); + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const { publishedfileids } = JSON.parse(url.searchParams.get('input_json') ?? '{}') as { publishedfileids: string[] }; + return Response.json({ response: { publishedfiledetails: publishedfileids.map(id => { + if (!roots.includes(id)) return mod(id, 'Resolved member'); + const calls = (detailCalls.get(id) ?? 0) + 1; + detailCalls.set(id, calls); + const children = [String(Number(id) + 1)]; + if (calls > 1) children.push(String(Number(id) + 2)); + return { ...mod(id, 'Growing collection', children), file_type: 2 }; + }) } }); + }); + try { + for (const [index, rootId] of roots.entries()) { + const initialMember = String(Number(rootId) + 1); + await db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)') + .bind(rootId, 'Growing collection', 1, 'OK', Date.now(), '', initialMember).run(); + const events: string[] = []; + const env: Env = { + DB: db, STEAM_API_KEY: 'fixture-only-key', + SCRAPE_QUEUE: { + async send(body: DependencyMessage, options?: QueueSendOptions) { + assert.deepEqual(body, { rootId }); + assert.equal(options?.delaySeconds, 30); + events.push('send'); + if (index === 1) throw new Error('Injected queue send failure'); + return { metadata: { metrics: { backlogCount: 1, backlogBytes: 0 } } }; + }, + } as Queue, + }; + await workerHandler.queue({ + queue: 'dependencies', + metadata: { metrics: { backlogCount: 1, backlogBytes: 0 } }, + messages: [{ + id: rootId, timestamp: new Date(), attempts: 1, body: { rootId }, + ack() { events.push('ack'); }, + retry() { events.push('retry'); }, + }], + ackAll() { assert.fail('Expected per-message acknowledgement'); }, + retryAll() { assert.fail('Expected per-message retry'); }, + }, env); + assert.deepEqual(events, index === 0 ? ['send', 'ack'] : ['send', 'retry']); + } + } finally { + await db.prepare('DELETE FROM items WHERE id BETWEEN ? AND ?').bind('80101', '80113').run(); + } +}); + test('an unconfigured Steam key does not turn a missing catalog into a successful empty page', async () => { const noKey = new Miniflare({ workers: [{ config: { @@ -928,3 +1044,88 @@ test('indexMods updates FTS when description changes only beyond the first 300 c } }); + +test('index crawl retries failed index writes without advancing or marking ready while requests remain best-effort', async (t) => { + const isolated = new Miniflare({ workers: [{ + config: { + name: 'index-write-failure', compatibilityDate: '2026-10-05', + manifest: { mainModule: 'worker.js', modules: { 'worker.js': { type: 'esm', contents: await readFile(resolvePath('dist/worker.js'), 'utf8') } } }, + env: { + DB: { type: 'd1', id: 'index-write-failure' }, + STEAM_API_KEY: { type: 'text', value: 'fixture-only-key' }, + }, + }, + dev: { outboundService: { type: 'fetcher', handler: upstream } }, + }] }); + const id = '80201'; + files[id] = mod(id, 'IndexWriteFailureFixture'); + let nextCursor = 'retry-page'; + const cursors: string[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL) => { + const request = new Request(input); + const url = new URL(request.url); + if (!url.pathname.includes('QueryFiles')) return await upstream(request); + const { cursor } = JSON.parse(url.searchParams.get('input_json') ?? '{}') as { cursor: string }; + cursors.push(cursor); + return Response.json({ response: { + total: 1, publishedfiledetails: [files[id]], next_cursor: nextCursor, + } }); + }); + try { + const database = await isolated.getD1Database('DB'); + await migrate(database); + const failIndexWrites = async () => { + await database.prepare(`CREATE TRIGGER fail_index_write BEFORE INSERT ON catalog_index + WHEN NEW.id = '${id}' BEGIN SELECT RAISE(ABORT, 'Injected search index failure'); END`).run(); + }; + const state = async () => await database.prepare( + 'SELECT cursor, indexed, full_at, cycle_started_at FROM index_state WHERE id = 1', + ).first<{ cursor: string; indexed: number; full_at: number; cycle_started_at: number }>(); + await failIndexWrites(); + const response = await isolated.dispatchFetch(`https://index-write-failure.test/catalog/mods/${id}`); + assert.equal(response.status, 200); + assert.equal((await response.json() as CatalogMod).id, id); + assert.ok(await database.prepare('SELECT id FROM catalog_items WHERE id = ?').bind(id).first()); + assert.equal(await database.prepare('SELECT id FROM catalog_index WHERE id = ?').bind(id).first(), null); + const env: Env = { DB: database, STEAM_API_KEY: 'fixture-only-key' }; + await assert.rejects(indexTick(env), /Injected search index failure/); + assert.deepEqual(await state(), { cursor: '*', indexed: 0, full_at: 0, cycle_started_at: 0 }); + + await database.prepare('DROP TRIGGER fail_index_write').run(); + await indexTick(env); + const pending = await state(); + assert.equal(pending?.cursor, 'retry-page'); + assert.equal(pending?.indexed, 1); + assert.equal(pending?.full_at, 0); + assert.ok((pending?.cycle_started_at ?? 0) > 0); + assert.ok(await database.prepare('SELECT id FROM catalog_index WHERE id = ?').bind(id).first()); + + nextCursor = ''; + await failIndexWrites(); + await assert.rejects(indexTick(env), /Injected search index failure/); + assert.deepEqual(await state(), pending); + await database.prepare('DROP TRIGGER fail_index_write').run(); + await indexTick(env); + const complete = await state(); + assert.equal(complete?.cursor, '*'); + assert.equal(complete?.cycle_started_at, 0); + assert.ok((complete?.full_at ?? 0) > 0); + assert.deepEqual(cursors, ['*', '*', 'retry-page', 'retry-page']); + + await database.prepare('UPDATE catalog_items SET updated_at = 0 WHERE id = ?').bind(id).run(); + await database.prepare("UPDATE index_state SET cursor = 'verify:0:', cycle_started_at = ?, full_at = 0 WHERE id = 1").bind(Date.now()).run(); + const verifying = await state(); + await failIndexWrites(); + await assert.rejects(indexTick(env), /Injected search index failure/); + assert.deepEqual(await state(), verifying); + const stale = await database.prepare('SELECT updated_at FROM catalog_items WHERE id = ?').bind(id).first<{ updated_at: number }>(); + assert.equal(stale?.updated_at, 0); + await database.prepare('DROP TRIGGER fail_index_write').run(); + await indexTick(env); + assert.equal((await state())?.cycle_started_at, 0); + assert.ok(((await state())?.full_at ?? 0) > 0); + } finally { + delete files[id]; + await isolated.dispose(); + } +}); diff --git a/cf-workers/worker.ts b/cf-workers/worker.ts index 3022544..e31ad51 100644 --- a/cf-workers/worker.ts +++ b/cf-workers/worker.ts @@ -90,7 +90,6 @@ class LRUMap { } has(key: string): boolean { return this._map.has(key); } delete(key: string): boolean { return this._map.delete(key); } - get size(): number { return this._map.size; } } const GLOBAL_RAM_CACHE = new LRUMap(10000); @@ -270,27 +269,22 @@ function buildTree(targetId: string): { itemsMap: Record visited.add(currentId); const cached = GLOBAL_RAM_CACHE.get(currentId); - if (cached) { - const ttl = cached.status === 'OK' - ? (cached.data.is_collection ? CACHE_TTL_OK_COLLECTION : CACHE_TTL_OK_MOD) - : CACHE_TTL_ERROR; - - if ((now - cached.timestamp) < ttl && cached.status !== 'PENDING') { - itemsMap[currentId] = cached.data; - if (Array.isArray(cached.data.deps)) { - queue.push(...cached.data.deps); - } - continue; - } - - if (Array.isArray(cached.data.deps)) { - queue.push(...cached.data.deps); - } - + if (!cached) { missingIds.add(currentId); + continue; + } + const ttl = cached.status === 'OK' + ? (cached.data.is_collection ? CACHE_TTL_OK_COLLECTION : CACHE_TTL_OK_MOD) + : CACHE_TTL_ERROR; + + if ((now - cached.timestamp) < ttl && cached.status !== 'PENDING') { + itemsMap[currentId] = cached.data; } else { missingIds.add(currentId); } + if (Array.isArray(cached.data.deps)) { + queue.push(...cached.data.deps); + } } return { itemsMap, missingIds }; @@ -335,7 +329,7 @@ async function fetchSteamApiDetails(apiKey: string | undefined, modIds: string[] } } -function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem[] } { +function processApiResults(details: SteamFile[]): ApiDependencyItem[] { const processed: ApiDependencyItem[] = []; for (const detail of details) { @@ -362,7 +356,7 @@ function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem }); } - return { processed }; + return processed; } // ============================================================================ @@ -530,7 +524,6 @@ async function ensureCollectionRootDeps(env: Env, rootId: string): Promise c.publishedfileid).filter(Boolean); - if (children.length === 0) return false; const currentDeps = cached.data.deps || []; const depsChanged = children.length !== currentDeps.length || @@ -652,7 +645,7 @@ async function scrapeInline(env: Env, missingIds: Set, rootId: string | continue; } - const { processed } = processApiResults(apiDetails); + const processed = processApiResults(apiDetails); const respondedIds = new Set(processed.map(m => m.id)); for (const id of batch) { @@ -691,12 +684,10 @@ async function scrapeInline(env: Env, missingIds: Set, rootId: string | scraped.add(mod.id); scrapedCount++; - if (depsToSave.length > 0) { - for (const dep of depsToSave) { - if (!scraped.has(dep) && !inQueue.has(dep)) { - toScrape.push(dep); - inQueue.add(dep); - } + for (const dep of depsToSave) { + if (!scraped.has(dep) && !inQueue.has(dep)) { + toScrape.push(dep); + inQueue.add(dep); } } } @@ -889,16 +880,15 @@ export default { const check = buildTree(rootId); if (check.missingIds.size === 0) { console.log(`[QUEUE COMPLETE] Root ${rootId} fully resolved`); - message.ack(); } else { console.log(`[QUEUE CONTINUE] Root ${rootId}: ${check.missingIds.size} still missing, re-scheduling`); if (!env.SCRAPE_QUEUE) throw new Error('SCRAPE_QUEUE not configured'); - message.ack(); await env.SCRAPE_QUEUE.send( { rootId }, { delaySeconds: 30 } ); } + message.ack(); } catch (err) { console.error(`[QUEUE ERROR] Root ${rootId}:`, err instanceof Error ? err.message : err); message.retry(); diff --git a/cmake/CPM.cmake b/cmake/CPM.cmake new file mode 100644 index 0000000..b7075fb --- /dev/null +++ b/cmake/CPM.cmake @@ -0,0 +1,1386 @@ +# CPM.cmake - CMake's missing package manager +# =========================================== +# See https://github.com/cpm-cmake/CPM.cmake for usage and update instructions. +# +# MIT License +# ----------- +#[[ + Copyright (c) 2019-2023 Lars Melchior and contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. +]] + +cmake_minimum_required(VERSION 3.14 FATAL_ERROR) + +# Initialize logging prefix +if(NOT CPM_INDENT) + set(CPM_INDENT + "CPM:" + CACHE INTERNAL "" + ) +endif() + +if(NOT COMMAND cpm_message) + function(cpm_message) + message(${ARGV}) + endfunction() +endif() + +if(DEFINED EXTRACTED_CPM_VERSION) + set(CURRENT_CPM_VERSION "${EXTRACTED_CPM_VERSION}${CPM_DEVELOPMENT}") +else() + set(CURRENT_CPM_VERSION 0.43.2) +endif() + +get_filename_component(CPM_CURRENT_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" REALPATH) +if(CPM_DIRECTORY) + if(NOT CPM_DIRECTORY STREQUAL CPM_CURRENT_DIRECTORY) + if(CPM_VERSION VERSION_LESS CURRENT_CPM_VERSION) + message( + AUTHOR_WARNING + "${CPM_INDENT} \ +A dependency is using a more recent CPM version (${CURRENT_CPM_VERSION}) than the current project (${CPM_VERSION}). \ +It is recommended to upgrade CPM to the most recent version. \ +See https://github.com/cpm-cmake/CPM.cmake for more information." + ) + endif() + if(${CMAKE_VERSION} VERSION_LESS "3.17.0") + include(FetchContent) + endif() + return() + endif() + + get_property( + CPM_INITIALIZED GLOBAL "" + PROPERTY CPM_INITIALIZED + SET + ) + if(CPM_INITIALIZED) + return() + endif() +endif() + +if(CURRENT_CPM_VERSION MATCHES "development-version") + message( + WARNING "${CPM_INDENT} Your project is using an unstable development version of CPM.cmake. \ +Please update to a recent release if possible. \ +See https://github.com/cpm-cmake/CPM.cmake for details." + ) +endif() + +set_property(GLOBAL PROPERTY CPM_INITIALIZED true) + +macro(cpm_set_policies) + # the policy allows us to change options without caching + cmake_policy(SET CMP0077 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0077 NEW) + + # the policy allows us to change set(CACHE) without caching + if(POLICY CMP0126) + cmake_policy(SET CMP0126 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0126 NEW) + endif() + + # The policy uses the download time for timestamp, instead of the timestamp in the archive. This + # allows for proper rebuilds when a projects url changes + if(POLICY CMP0135) + cmake_policy(SET CMP0135 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0135 NEW) + endif() + + # treat relative git repository paths as being relative to the parent project's remote + if(POLICY CMP0150) + cmake_policy(SET CMP0150 NEW) + set(CMAKE_POLICY_DEFAULT_CMP0150 NEW) + endif() +endmacro() +cpm_set_policies() + +option(CPM_USE_LOCAL_PACKAGES "Always try to use `find_package` to get dependencies" + $ENV{CPM_USE_LOCAL_PACKAGES} +) +option(CPM_LOCAL_PACKAGES_ONLY "Only use `find_package` to get dependencies" + $ENV{CPM_LOCAL_PACKAGES_ONLY} +) +option(CPM_DOWNLOAD_ALL "Always download dependencies from source" $ENV{CPM_DOWNLOAD_ALL}) +option(CPM_DONT_UPDATE_MODULE_PATH "Don't update the module path to allow using find_package" + $ENV{CPM_DONT_UPDATE_MODULE_PATH} +) +option(CPM_DONT_CREATE_PACKAGE_LOCK "Don't create a package lock file in the binary path" + $ENV{CPM_DONT_CREATE_PACKAGE_LOCK} +) +option(CPM_INCLUDE_ALL_IN_PACKAGE_LOCK + "Add all packages added through CPM.cmake to the package lock" + $ENV{CPM_INCLUDE_ALL_IN_PACKAGE_LOCK} +) +option(CPM_USE_NAMED_CACHE_DIRECTORIES + "Use additional directory of package name in cache on the most nested level." + $ENV{CPM_USE_NAMED_CACHE_DIRECTORIES} +) + +set(CPM_VERSION + ${CURRENT_CPM_VERSION} + CACHE INTERNAL "" +) +set(CPM_DIRECTORY + ${CPM_CURRENT_DIRECTORY} + CACHE INTERNAL "" +) +set(CPM_FILE + ${CMAKE_CURRENT_LIST_FILE} + CACHE INTERNAL "" +) +set(CPM_PACKAGES + "" + CACHE INTERNAL "" +) +set(CPM_DRY_RUN + OFF + CACHE INTERNAL "Don't download or configure dependencies (for testing)" +) + +if(DEFINED ENV{CPM_SOURCE_CACHE}) + set(CPM_SOURCE_CACHE_DEFAULT $ENV{CPM_SOURCE_CACHE}) +else() + set(CPM_SOURCE_CACHE_DEFAULT OFF) +endif() + +set(CPM_SOURCE_CACHE + ${CPM_SOURCE_CACHE_DEFAULT} + CACHE PATH "Directory to download CPM dependencies" +) + +if(NOT CPM_DONT_UPDATE_MODULE_PATH AND NOT DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + set(CPM_MODULE_PATH + "${CMAKE_BINARY_DIR}/CPM_modules" + CACHE INTERNAL "" + ) + # remove old modules + file(REMOVE_RECURSE ${CPM_MODULE_PATH}) + file(MAKE_DIRECTORY ${CPM_MODULE_PATH}) + # locally added CPM modules should override global packages + set(CMAKE_MODULE_PATH "${CPM_MODULE_PATH};${CMAKE_MODULE_PATH}") +endif() + +if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + set(CPM_PACKAGE_LOCK_FILE + "${CMAKE_BINARY_DIR}/cpm-package-lock.cmake" + CACHE INTERNAL "" + ) + file(WRITE ${CPM_PACKAGE_LOCK_FILE} + "# CPM Package Lock\n# This file should be committed to version control\n\n" + ) +endif() + +include(FetchContent) + +# Try to infer package name from git repository uri (path or url) +function(cpm_package_name_from_git_uri URI RESULT) + if("${URI}" MATCHES "([^/:]+)/?.git/?$") + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + else() + unset(${RESULT} PARENT_SCOPE) + endif() +endfunction() + +# Find the shortest hash that can be used eg, if origin_hash is +# cccb77ae9609d2768ed80dd42cec54f77b1f1455 the following files will be checked, until one is found +# that is either empty (allowing us to assign origin_hash), or whose contents matches ${origin_hash} +# +# * .../cccb.hash +# * .../cccb77ae.hash +# * .../cccb77ae9609.hash +# * .../cccb77ae9609d276.hash +# * etc +# +# We will be able to use a shorter path with very high probability, but in the (rare) event that the +# first couple characters collide, we will check longer and longer substrings. +function(cpm_get_shortest_hash source_cache_dir origin_hash short_hash_output_var) + # for compatibility with caches populated by a previous version of CPM, check if a directory using + # the full hash already exists + if(EXISTS "${source_cache_dir}/${origin_hash}") + set(${short_hash_output_var} + "${origin_hash}" + PARENT_SCOPE + ) + return() + endif() + + foreach(len RANGE 4 40 4) + string(SUBSTRING "${origin_hash}" 0 ${len} short_hash) + set(hash_lock ${source_cache_dir}/${short_hash}.lock) + set(hash_fp ${source_cache_dir}/${short_hash}.hash) + # Take a lock, so we don't have a race condition with another instance of cmake. We will release + # this lock when we can, however, if there is an error, we want to ensure it gets released on + # it's own on exit from the function. + file(LOCK ${hash_lock} GUARD FUNCTION) + + # Load the contents of .../${short_hash}.hash + file(TOUCH ${hash_fp}) + file(READ ${hash_fp} hash_fp_contents) + + if(hash_fp_contents STREQUAL "") + # Write the origin hash + file(WRITE ${hash_fp} ${origin_hash}) + file(LOCK ${hash_lock} RELEASE) + break() + elseif(hash_fp_contents STREQUAL origin_hash) + file(LOCK ${hash_lock} RELEASE) + break() + else() + file(LOCK ${hash_lock} RELEASE) + endif() + endforeach() + set(${short_hash_output_var} + "${short_hash}" + PARENT_SCOPE + ) +endfunction() + +# Try to infer package name and version from a url +function(cpm_package_name_and_ver_from_url url outName outVer) + if(url MATCHES + "[/\\?]([a-zA-Z0-9_\\.-]+)\\.(tar|tar\\.gz|tar\\.bz2|tar\\.xz|tar\\.zst|zip|ZIP)(\\?|/|$)" + ) + # We matched an archive + set(filename "${CMAKE_MATCH_1}") + + if(filename MATCHES "([a-zA-Z0-9_\\.-]+)[_-]v?(([0-9]+\\.)*[0-9]+[a-zA-Z0-9]*)") + # We matched - (ie foo-1.2.3) + set(${outName} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + set(${outVer} + "${CMAKE_MATCH_2}" + PARENT_SCOPE + ) + elseif(filename MATCHES "(([0-9]+\\.)+[0-9]+[a-zA-Z0-9]*)") + # We couldn't find a name, but we found a version + # + # In many cases (which we don't handle here) the url would look something like + # `irrelevant/ACTUAL_PACKAGE_NAME/irrelevant/1.2.3.zip`. In such a case we can't possibly + # distinguish the package name from the irrelevant bits. Moreover if we try to match the + # package name from the filename, we'd get bogus at best. + unset(${outName} PARENT_SCOPE) + set(${outVer} + "${CMAKE_MATCH_1}" + PARENT_SCOPE + ) + else() + # Boldly assume that the file name is the package name. + # + # Yes, something like `irrelevant/ACTUAL_NAME/irrelevant/download.zip` will ruin our day, but + # such cases should be quite rare. No popular service does this... we think. + set(${outName} + "${filename}" + PARENT_SCOPE + ) + unset(${outVer} PARENT_SCOPE) + endif() + else() + # No ideas yet what to do with non-archives + unset(${outName} PARENT_SCOPE) + unset(${outVer} PARENT_SCOPE) + endif() +endfunction() + +function(cpm_find_package NAME VERSION) + string(REPLACE " " ";" EXTRA_ARGS "${ARGN}") + find_package(${NAME} ${VERSION} ${EXTRA_ARGS} QUIET) + if(${CPM_ARGS_NAME}_FOUND) + if(DEFINED ${CPM_ARGS_NAME}_VERSION) + set(VERSION ${${CPM_ARGS_NAME}_VERSION}) + endif() + cpm_message(STATUS "${CPM_INDENT} Using local package ${CPM_ARGS_NAME}@${VERSION}") + CPMRegisterPackage(${CPM_ARGS_NAME} "${VERSION}") + set(CPM_PACKAGE_FOUND + YES + PARENT_SCOPE + ) + else() + set(CPM_PACKAGE_FOUND + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Create a custom FindXXX.cmake module for a CPM package This prevents `find_package(NAME)` from +# finding the system library +function(cpm_create_module_file Name) + if(NOT CPM_DONT_UPDATE_MODULE_PATH) + if(DEFINED CMAKE_FIND_PACKAGE_REDIRECTS_DIR) + # Redirect find_package calls to the CPM package. This is what FetchContent does when you set + # OVERRIDE_FIND_PACKAGE. The CMAKE_FIND_PACKAGE_REDIRECTS_DIR works for find_package in CONFIG + # mode, unlike the Find${Name}.cmake fallback. CMAKE_FIND_PACKAGE_REDIRECTS_DIR is not defined + # in script mode, or in CMake < 3.24. + # https://cmake.org/cmake/help/latest/module/FetchContent.html#fetchcontent-find-package-integration-examples + string(TOLOWER ${Name} NameLower) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config.cmake + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${NameLower}-extra.cmake\" OPTIONAL)\n" + "include(\"\${CMAKE_CURRENT_LIST_DIR}/${Name}Extra.cmake\" OPTIONAL)\n" + ) + file(WRITE ${CMAKE_FIND_PACKAGE_REDIRECTS_DIR}/${NameLower}-config-version.cmake + "set(PACKAGE_VERSION_COMPATIBLE TRUE)\n" "set(PACKAGE_VERSION_EXACT TRUE)\n" + ) + else() + file(WRITE ${CPM_MODULE_PATH}/Find${Name}.cmake + "include(\"${CPM_FILE}\")\n${ARGN}\nset(${Name}_FOUND TRUE)" + ) + endif() + endif() +endfunction() + +# Find a package locally or fallback to CPMAddPackage +function(CPMFindPackage) + set(oneValueArgs NAME VERSION GIT_TAG FIND_PACKAGE_ARGUMENTS) + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "" ${ARGN}) + + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + set(downloadPackage ${CPM_DOWNLOAD_ALL}) + if(DEFINED CPM_DOWNLOAD_${CPM_ARGS_NAME}) + set(downloadPackage ${CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + elseif(DEFINED ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + set(downloadPackage $ENV{CPM_DOWNLOAD_${CPM_ARGS_NAME}}) + endif() + if(downloadPackage) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(NOT CPM_PACKAGE_FOUND) + CPMAddPackage(${ARGN}) + cpm_export_variables(${CPM_ARGS_NAME}) + endif() + +endfunction() + +# checks if a package has been added before +function(cpm_check_if_package_already_added CPM_ARGS_NAME CPM_ARGS_VERSION) + if("${CPM_ARGS_NAME}" IN_LIST CPM_PACKAGES) + CPMGetPackageVersion(${CPM_ARGS_NAME} CPM_PACKAGE_VERSION) + if("${CPM_PACKAGE_VERSION}" VERSION_LESS "${CPM_ARGS_VERSION}") + message( + WARNING + "${CPM_INDENT} Requires a newer version of ${CPM_ARGS_NAME} (${CPM_ARGS_VERSION}) than currently included (${CPM_PACKAGE_VERSION})." + ) + endif() + cpm_get_fetch_properties(${CPM_ARGS_NAME}) + set(${CPM_ARGS_NAME}_ADDED NO) + set(CPM_PACKAGE_ALREADY_ADDED + YES + PARENT_SCOPE + ) + cpm_export_variables(${CPM_ARGS_NAME}) + else() + set(CPM_PACKAGE_ALREADY_ADDED + NO + PARENT_SCOPE + ) + endif() +endfunction() + +# Parse the argument of CPMAddPackage in case a single one was provided and convert it to a list of +# arguments which can then be parsed idiomatically. For example gh:foo/bar@1.2.3 will be converted +# to: GITHUB_REPOSITORY;foo/bar;VERSION;1.2.3 +function(cpm_parse_add_package_single_arg arg outArgs) + # Look for a scheme + if("${arg}" MATCHES "^([a-zA-Z]+):(.+)$") + string(TOLOWER "${CMAKE_MATCH_1}" scheme) + set(uri "${CMAKE_MATCH_2}") + + # Check for CPM-specific schemes + if(scheme STREQUAL "gh") + set(out "GITHUB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "gl") + set(out "GITLAB_REPOSITORY;${uri}") + set(packageType "git") + elseif(scheme STREQUAL "bb") + set(out "BITBUCKET_REPOSITORY;${uri}") + set(packageType "git") + # A CPM-specific scheme was not found. Looks like this is a generic URL so try to determine + # type + elseif(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Fall back to a URL + set(out "URL;${arg}") + set(packageType "archive") + + # We could also check for SVN since FetchContent supports it, but SVN is so rare these days. + # We just won't bother with the additional complexity it will induce in this function. SVN is + # done by multi-arg + endif() + else() + if(arg MATCHES ".git/?(@|#|$)") + set(out "GIT_REPOSITORY;${arg}") + set(packageType "git") + else() + # Give up + message(FATAL_ERROR "${CPM_INDENT} Can't determine package type of '${arg}'") + endif() + endif() + + # For all packages we interpret @... as version. Only replace the last occurrence. Thus URIs + # containing '@' can be used + string(REGEX REPLACE "@([^@]+)$" ";VERSION;\\1" out "${out}") + + # Parse the rest according to package type + if(packageType STREQUAL "git") + # For git repos we interpret #... as a tag or branch or commit hash + string(REGEX REPLACE "#([^#]+)$" ";GIT_TAG;\\1" out "${out}") + elseif(packageType STREQUAL "archive") + # For archives we interpret #... as a URL hash. + string(REGEX REPLACE "#([^#]+)$" ";URL_HASH;\\1" out "${out}") + # We don't try to parse the version if it's not provided explicitly. cpm_get_version_from_url + # should do this at a later point + else() + # We should never get here. This is an assertion and hitting it means there's a problem with the + # code above. A packageType was set, but not handled by this if-else. + message(FATAL_ERROR "${CPM_INDENT} Unsupported package type '${packageType}' of '${arg}'") + endif() + + set(${outArgs} + ${out} + PARENT_SCOPE + ) +endfunction() + +# Check that the working directory for a git repo is clean +function(cpm_check_git_working_dir_is_clean repoPath gitTag isClean) + + find_package(Git REQUIRED) + + if(NOT GIT_EXECUTABLE) + # No git executable, assume directory is clean + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + # check for uncommitted changes + execute_process( + COMMAND ${GIT_EXECUTABLE} status --porcelain + RESULT_VARIABLE resultGitStatus + OUTPUT_VARIABLE repoStatus + OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET + WORKING_DIRECTORY ${repoPath} + ) + if(resultGitStatus) + # not supposed to happen, assume clean anyway + message(WARNING "${CPM_INDENT} Calling git status on folder ${repoPath} failed") + set(${isClean} + TRUE + PARENT_SCOPE + ) + return() + endif() + + if(NOT "${repoStatus}" STREQUAL "") + set(${isClean} + FALSE + PARENT_SCOPE + ) + return() + endif() + + # check for committed changes + execute_process( + COMMAND ${GIT_EXECUTABLE} diff -s --exit-code ${gitTag} + RESULT_VARIABLE resultGitDiff + OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_QUIET + WORKING_DIRECTORY ${repoPath} + ) + + if(${resultGitDiff} EQUAL 0) + set(${isClean} + TRUE + PARENT_SCOPE + ) + else() + set(${isClean} + FALSE + PARENT_SCOPE + ) + endif() + +endfunction() + +# Add PATCH_COMMAND to CPM_ARGS_UNPARSED_ARGUMENTS. This method consumes a list of files in ARGN +# then generates a `PATCH_COMMAND` appropriate for `ExternalProject_Add()`. This command is appended +# to the parent scope's `CPM_ARGS_UNPARSED_ARGUMENTS`. +function(cpm_add_patches) + # Return if no patch files are supplied. + if(NOT ARGN) + return() + endif() + + # Find the patch program. + find_program(PATCH_EXECUTABLE patch) + if(CMAKE_HOST_WIN32 AND NOT PATCH_EXECUTABLE) + # The Windows git executable is distributed with patch.exe. Find the path to the executable, if + # it exists, then search `../usr/bin` and `../../usr/bin` for patch.exe. + find_package(Git QUIET) + if(GIT_EXECUTABLE) + get_filename_component(extra_search_path ${GIT_EXECUTABLE} DIRECTORY) + get_filename_component(extra_search_path_1up ${extra_search_path} DIRECTORY) + get_filename_component(extra_search_path_2up ${extra_search_path_1up} DIRECTORY) + find_program( + PATCH_EXECUTABLE patch HINTS "${extra_search_path_1up}/usr/bin" + "${extra_search_path_2up}/usr/bin" + ) + endif() + endif() + if(NOT PATCH_EXECUTABLE) + message(FATAL_ERROR "Couldn't find `patch` executable to use with PATCHES keyword.") + endif() + + # Create a temporary + set(temp_list ${CPM_ARGS_UNPARSED_ARGUMENTS}) + + # Ensure each file exists (or error out) and add it to the list. + set(first_item True) + foreach(PATCH_FILE ${ARGN}) + # Make sure the patch file exists, if we can't find it, try again in the current directory. + if(NOT EXISTS "${PATCH_FILE}") + if(NOT EXISTS "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + message(FATAL_ERROR "Couldn't find patch file: '${PATCH_FILE}'") + endif() + set(PATCH_FILE "${CMAKE_CURRENT_LIST_DIR}/${PATCH_FILE}") + endif() + + # Convert to absolute path for use with patch file command. + get_filename_component(PATCH_FILE "${PATCH_FILE}" ABSOLUTE) + + # The first patch entry must be preceded by "PATCH_COMMAND" while the following items are + # preceded by "&&". + if(first_item) + set(first_item False) + list(APPEND temp_list "PATCH_COMMAND") + else() + list(APPEND temp_list "&&") + endif() + # Add the patch command to the list + list(APPEND temp_list "${PATCH_EXECUTABLE}" "-p1" "<" "${PATCH_FILE}") + endforeach() + + # Move temp out into parent scope. + set(CPM_ARGS_UNPARSED_ARGUMENTS + ${temp_list} + PARENT_SCOPE + ) + +endfunction() + +# method to overwrite internal FetchContent properties, to allow using CPM.cmake to overload +# FetchContent calls. As these are internal cmake properties, this method should be used carefully +# and may need modification in future CMake versions. Source: +# https://github.com/Kitware/CMake/blob/dc3d0b5a0a7d26d43d6cfeb511e224533b5d188f/Modules/FetchContent.cmake#L1152 +function(cpm_override_fetchcontent contentName) + cmake_parse_arguments(PARSE_ARGV 1 arg "" "SOURCE_DIR;BINARY_DIR" "") + if(NOT "${arg_UNPARSED_ARGUMENTS}" STREQUAL "") + message(FATAL_ERROR "${CPM_INDENT} Unsupported arguments: ${arg_UNPARSED_ARGUMENTS}") + endif() + + string(TOLOWER ${contentName} contentNameLower) + set(prefix "_FetchContent_${contentNameLower}") + + set(propertyName "${prefix}_sourceDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_SOURCE_DIR}") + + set(propertyName "${prefix}_binaryDir") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} "${arg_BINARY_DIR}") + + set(propertyName "${prefix}_populated") + define_property( + GLOBAL + PROPERTY ${propertyName} + BRIEF_DOCS "Internal implementation detail of FetchContent_Populate()" + FULL_DOCS "Details used by FetchContent_Populate() for ${contentName}" + ) + set_property(GLOBAL PROPERTY ${propertyName} TRUE) +endfunction() + +# Download and add a package from source +function(CPMAddPackage) + cpm_set_policies() + + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + CUSTOM_CACHE_KEY + ) + + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND PATCHES) + + list(LENGTH ARGN argnLength) + + # Parse single shorthand argument + if(argnLength EQUAL 1) + cpm_parse_add_package_single_arg("${ARGN}" ARGN) + + # The shorthand syntax implies EXCLUDE_FROM_ALL and SYSTEM + set(ARGN "${ARGN};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;") + + # Parse URI shorthand argument + elseif(argnLength GREATER 1 AND "${ARGV0}" STREQUAL "URI") + list(REMOVE_AT ARGN 0 1) # remove "URI gh:<...>@version#tag" + cpm_parse_add_package_single_arg("${ARGV1}" ARGV0) + + set(ARGN "${ARGV0};EXCLUDE_FROM_ALL;YES;SYSTEM;YES;${ARGN}") + endif() + + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" "${ARGN}") + + # Set default values for arguments + if(NOT DEFINED CPM_ARGS_VERSION) + if(DEFINED CPM_ARGS_GIT_TAG) + cpm_get_version_from_git_tag("${CPM_ARGS_GIT_TAG}" CPM_ARGS_VERSION) + endif() + endif() + + if(CPM_ARGS_DOWNLOAD_ONLY) + set(DOWNLOAD_ONLY ${CPM_ARGS_DOWNLOAD_ONLY}) + else() + set(DOWNLOAD_ONLY NO) + endif() + + if(DEFINED CPM_ARGS_GITHUB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://github.com/${CPM_ARGS_GITHUB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_GITLAB_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://gitlab.com/${CPM_ARGS_GITLAB_REPOSITORY}.git") + elseif(DEFINED CPM_ARGS_BITBUCKET_REPOSITORY) + set(CPM_ARGS_GIT_REPOSITORY "https://bitbucket.org/${CPM_ARGS_BITBUCKET_REPOSITORY}.git") + endif() + + if(DEFINED CPM_ARGS_GIT_REPOSITORY) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_REPOSITORY ${CPM_ARGS_GIT_REPOSITORY}) + if(NOT DEFINED CPM_ARGS_GIT_TAG) + set(CPM_ARGS_GIT_TAG v${CPM_ARGS_VERSION}) + endif() + + # If a name wasn't provided, try to infer it from the git repo + if(NOT DEFINED CPM_ARGS_NAME) + cpm_package_name_from_git_uri(${CPM_ARGS_GIT_REPOSITORY} CPM_ARGS_NAME) + endif() + endif() + + set(CPM_SKIP_FETCH FALSE) + + if(DEFINED CPM_ARGS_GIT_TAG) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_TAG ${CPM_ARGS_GIT_TAG}) + # If GIT_SHALLOW is explicitly specified, honor the value. + if(DEFINED CPM_ARGS_GIT_SHALLOW) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW ${CPM_ARGS_GIT_SHALLOW}) + endif() + endif() + + if(DEFINED CPM_ARGS_URL) + # If a name or version aren't provided, try to infer them from the URL + list(GET CPM_ARGS_URL 0 firstUrl) + cpm_package_name_and_ver_from_url(${firstUrl} nameFromUrl verFromUrl) + # If we fail to obtain name and version from the first URL, we could try other URLs if any. + # However multiple URLs are expected to be quite rare, so for now we won't bother. + + # If the caller provided their own name and version, they trump the inferred ones. + if(NOT DEFINED CPM_ARGS_NAME) + set(CPM_ARGS_NAME ${nameFromUrl}) + endif() + if(NOT DEFINED CPM_ARGS_VERSION) + set(CPM_ARGS_VERSION ${verFromUrl}) + endif() + + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS URL "${CPM_ARGS_URL}") + endif() + + # Check for required arguments + + if(NOT DEFINED CPM_ARGS_NAME) + message( + FATAL_ERROR + "${CPM_INDENT} 'NAME' was not provided and couldn't be automatically inferred for package added with arguments: '${ARGN}'" + ) + endif() + + # Check if package has been added before + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + if(CPM_PACKAGE_ALREADY_ADDED) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + if(NOT DEFINED CPM_${CPM_ARGS_NAME}_SOURCE AND DEFINED ENV{CPM_${CPM_ARGS_NAME}_SOURCE}) + # Normalize separators to support Windows paths when reading from environment variables. + file(TO_CMAKE_PATH "$ENV{CPM_${CPM_ARGS_NAME}_SOURCE}" CPM_${CPM_ARGS_NAME}_SOURCE) + message(WARNING "${CPM_INDENT} '${CPM_ARGS_NAME}' version overridden by environment variable " + "CPM_${CPM_ARGS_NAME}_SOURCE='${CPM_${CPM_ARGS_NAME}_SOURCE}'" + ) + endif() + + # Check for manual overrides + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_${CPM_ARGS_NAME}_SOURCE}" STREQUAL "") + set(PACKAGE_SOURCE ${CPM_${CPM_ARGS_NAME}_SOURCE}) + set(CPM_${CPM_ARGS_NAME}_SOURCE "") + if(NOT DEFINED ENV{CPM_${CPM_ARGS_NAME}_SOURCE}) + message(WARNING "${CPM_INDENT} '${CPM_ARGS_NAME}' version overridden by CMake variable " + "CPM_${CPM_ARGS_NAME}_SOURCE='${PACKAGE_SOURCE}'" + ) + endif() + # Preserve semicolons in option values across the recursive cpmaddpackage() call. + set(_forwarded_options) + foreach(opt IN LISTS CPM_ARGS_OPTIONS) + string(REPLACE ";" "\\\\;" opt "${opt}") + list(APPEND _forwarded_options "${opt}") + endforeach() + + CPMAddPackage( + NAME "${CPM_ARGS_NAME}" + SOURCE_DIR "${PACKAGE_SOURCE}" + EXCLUDE_FROM_ALL "${CPM_ARGS_EXCLUDE_FROM_ALL}" + SYSTEM "${CPM_ARGS_SYSTEM}" + PATCHES "${CPM_ARGS_PATCHES}" + OPTIONS "${_forwarded_options}" + SOURCE_SUBDIR "${CPM_ARGS_SOURCE_SUBDIR}" + DOWNLOAD_ONLY "${DOWNLOAD_ONLY}" + FORCE True + ) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + # Check for available declaration + if(NOT CPM_ARGS_FORCE AND NOT "${CPM_DECLARATION_${CPM_ARGS_NAME}}" STREQUAL "") + set(declaration ${CPM_DECLARATION_${CPM_ARGS_NAME}}) + set(CPM_DECLARATION_${CPM_ARGS_NAME} "") + CPMAddPackage(${declaration}) + cpm_export_variables(${CPM_ARGS_NAME}) + # checking again to ensure version and option compatibility + cpm_check_if_package_already_added(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}") + return() + endif() + + if(NOT CPM_ARGS_FORCE) + if(CPM_USE_LOCAL_PACKAGES OR CPM_LOCAL_PACKAGES_ONLY) + cpm_find_package(${CPM_ARGS_NAME} "${CPM_ARGS_VERSION}" ${CPM_ARGS_FIND_PACKAGE_ARGUMENTS}) + + if(CPM_PACKAGE_FOUND) + cpm_export_variables(${CPM_ARGS_NAME}) + return() + endif() + + if(CPM_LOCAL_PACKAGES_ONLY) + message( + SEND_ERROR + "${CPM_INDENT} ${CPM_ARGS_NAME} not found via find_package(${CPM_ARGS_NAME} ${CPM_ARGS_VERSION})" + ) + endif() + endif() + endif() + + CPMRegisterPackage("${CPM_ARGS_NAME}" "${CPM_ARGS_VERSION}") + + if(DEFINED CPM_ARGS_GIT_TAG) + set(PACKAGE_INFO "${CPM_ARGS_GIT_TAG}") + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + set(PACKAGE_INFO "${CPM_ARGS_SOURCE_DIR}") + else() + set(PACKAGE_INFO "${CPM_ARGS_VERSION}") + endif() + + if(DEFINED FETCHCONTENT_BASE_DIR) + # respect user's FETCHCONTENT_BASE_DIR if set + set(CPM_FETCHCONTENT_BASE_DIR ${FETCHCONTENT_BASE_DIR}) + else() + set(CPM_FETCHCONTENT_BASE_DIR ${CMAKE_BINARY_DIR}/_deps) + endif() + + cpm_add_patches(${CPM_ARGS_PATCHES}) + + if(DEFINED CPM_ARGS_DOWNLOAD_COMMAND) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS DOWNLOAD_COMMAND ${CPM_ARGS_DOWNLOAD_COMMAND}) + elseif(DEFINED CPM_ARGS_SOURCE_DIR) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${CPM_ARGS_SOURCE_DIR}) + if(NOT IS_ABSOLUTE ${CPM_ARGS_SOURCE_DIR}) + # Expand `CPM_ARGS_SOURCE_DIR` relative path. This is important because EXISTS doesn't work + # for relative paths. + get_filename_component( + source_directory ${CPM_ARGS_SOURCE_DIR} REALPATH BASE_DIR ${CMAKE_CURRENT_BINARY_DIR} + ) + else() + set(source_directory ${CPM_ARGS_SOURCE_DIR}) + endif() + if(NOT EXISTS ${source_directory}) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild") + endif() + elseif(CPM_SOURCE_CACHE AND NOT CPM_ARGS_NO_CACHE) + string(TOLOWER ${CPM_ARGS_NAME} lower_case_name) + set(origin_parameters ${CPM_ARGS_UNPARSED_ARGUMENTS}) + list(SORT origin_parameters) + if(CPM_ARGS_CUSTOM_CACHE_KEY) + # Application set a custom unique directory name + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${CPM_ARGS_CUSTOM_CACHE_KEY}) + elseif(CPM_USE_NAMED_CACHE_DIRECTORIES) + string(SHA1 origin_hash "${origin_parameters};NEW_CACHE_STRUCTURE_TAG") + cpm_get_shortest_hash( + "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory + "${origin_hash}" # Input hash + origin_hash # Computed hash + ) + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}/${CPM_ARGS_NAME}) + else() + string(SHA1 origin_hash "${origin_parameters}") + cpm_get_shortest_hash( + "${CPM_SOURCE_CACHE}/${lower_case_name}" # source cache directory + "${origin_hash}" # Input hash + origin_hash # Computed hash + ) + set(download_directory ${CPM_SOURCE_CACHE}/${lower_case_name}/${origin_hash}) + endif() + # Expand `download_directory` relative path. This is important because EXISTS doesn't work for + # relative paths. + get_filename_component(download_directory ${download_directory} ABSOLUTE) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS SOURCE_DIR ${download_directory}) + + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock) + endif() + + if(EXISTS ${download_directory}) + if(CPM_SOURCE_CACHE) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} "${download_directory}" + "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + ) + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + + if(DEFINED CPM_ARGS_GIT_TAG AND NOT (PATCH_COMMAND IN_LIST CPM_ARGS_UNPARSED_ARGUMENTS)) + # warn if cache has been changed since checkout + cpm_check_git_working_dir_is_clean(${download_directory} ${CPM_ARGS_GIT_TAG} IS_CLEAN) + if(NOT ${IS_CLEAN}) + message( + WARNING "${CPM_INDENT} Cache for ${CPM_ARGS_NAME} (${download_directory}) is dirty" + ) + endif() + endif() + + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + set(PACKAGE_INFO "${PACKAGE_INFO} at ${download_directory}") + + # As the source dir is already cached/populated, we override the call to FetchContent. + set(CPM_SKIP_FETCH TRUE) + cpm_override_fetchcontent( + "${lower_case_name}" SOURCE_DIR "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + BINARY_DIR "${${CPM_ARGS_NAME}_BINARY_DIR}" + ) + + else() + # Enable shallow clone when GIT_TAG is not a commit hash. Our guess may not be accurate, but + # it should guarantee no commit hash get mis-detected. + if(NOT DEFINED CPM_ARGS_GIT_SHALLOW) + cpm_is_git_tag_commit_hash("${CPM_ARGS_GIT_TAG}" IS_HASH) + if(NOT ${IS_HASH}) + list(APPEND CPM_ARGS_UNPARSED_ARGUMENTS GIT_SHALLOW TRUE) + endif() + endif() + + # remove timestamps so CMake will re-download the dependency + file(REMOVE_RECURSE ${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild) + set(PACKAGE_INFO "${PACKAGE_INFO} to ${download_directory}") + endif() + endif() + + if(NOT "${DOWNLOAD_ONLY}") + cpm_create_module_file(${CPM_ARGS_NAME} "CPMAddPackage(\"${ARGN}\")") + endif() + + if(CPM_PACKAGE_LOCK_ENABLED) + if((CPM_ARGS_VERSION AND NOT CPM_ARGS_SOURCE_DIR) OR CPM_INCLUDE_ALL_IN_PACKAGE_LOCK) + cpm_add_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + elseif(CPM_ARGS_SOURCE_DIR) + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "local directory") + else() + cpm_add_comment_to_package_lock(${CPM_ARGS_NAME} "${ARGN}") + endif() + endif() + + cpm_message( + STATUS "${CPM_INDENT} Adding package ${CPM_ARGS_NAME}@${CPM_ARGS_VERSION} (${PACKAGE_INFO})" + ) + + if(NOT CPM_SKIP_FETCH) + # CMake 3.28 added EXCLUDE, SYSTEM (3.25), and SOURCE_SUBDIR (3.18) to FetchContent_Declare. + # Calling FetchContent_MakeAvailable will then internally forward these options to + # add_subdirectory. Up until these changes, we had to call FetchContent_Populate and + # add_subdirectory separately, which is no longer necessary and has been deprecated as of 3.30. + # A Bug in CMake prevents us to use the non-deprecated functions until 3.30.3. + set(fetchContentDeclareExtraArgs "") + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(${CPM_ARGS_EXCLUDE_FROM_ALL}) + list(APPEND fetchContentDeclareExtraArgs EXCLUDE_FROM_ALL) + endif() + if(${CPM_ARGS_SYSTEM}) + list(APPEND fetchContentDeclareExtraArgs SYSTEM) + endif() + if(DEFINED CPM_ARGS_SOURCE_SUBDIR) + list(APPEND fetchContentDeclareExtraArgs SOURCE_SUBDIR ${CPM_ARGS_SOURCE_SUBDIR}) + endif() + # For CMake version <3.28 OPTIONS are parsed in cpm_add_subdirectory + if(CPM_ARGS_OPTIONS AND NOT DOWNLOAD_ONLY) + foreach(OPTION ${CPM_ARGS_OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + endif() + cpm_declare_fetch( + "${CPM_ARGS_NAME}" ${fetchContentDeclareExtraArgs} "${CPM_ARGS_UNPARSED_ARGUMENTS}" + ) + + cpm_fetch_package("${CPM_ARGS_NAME}" ${DOWNLOAD_ONLY} populated ${CPM_ARGS_UNPARSED_ARGUMENTS}) + if(CPM_SOURCE_CACHE AND download_directory) + file(LOCK ${download_directory}/../cmake.lock RELEASE) + endif() + if(${populated} AND ${CMAKE_VERSION} VERSION_LESS "3.30.3") + cpm_add_subdirectory( + "${CPM_ARGS_NAME}" + "${DOWNLOAD_ONLY}" + "${${CPM_ARGS_NAME}_SOURCE_DIR}/${CPM_ARGS_SOURCE_SUBDIR}" + "${${CPM_ARGS_NAME}_BINARY_DIR}" + "${CPM_ARGS_EXCLUDE_FROM_ALL}" + "${CPM_ARGS_SYSTEM}" + "${CPM_ARGS_OPTIONS}" + ) + endif() + cpm_get_fetch_properties("${CPM_ARGS_NAME}") + endif() + + set(${CPM_ARGS_NAME}_ADDED YES) + cpm_export_variables("${CPM_ARGS_NAME}") +endfunction() + +# Fetch a previously declared package +macro(CPMGetPackage Name) + if(DEFINED "CPM_DECLARATION_${Name}") + CPMAddPackage(NAME ${Name}) + else() + message(SEND_ERROR "${CPM_INDENT} Cannot retrieve package ${Name}: no declaration available") + endif() +endmacro() + +# export variables available to the caller to the parent scope expects ${CPM_ARGS_NAME} to be set +macro(cpm_export_variables name) + set(${name}_SOURCE_DIR + "${${name}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${name}_BINARY_DIR + "${${name}_BINARY_DIR}" + PARENT_SCOPE + ) + set(${name}_ADDED + "${${name}_ADDED}" + PARENT_SCOPE + ) + set(CPM_LAST_PACKAGE_NAME + "${name}" + PARENT_SCOPE + ) +endmacro() + +# declares a package, so that any call to CPMAddPackage for the package name will use these +# arguments instead. Previous declarations will not be overridden. +macro(CPMDeclarePackage Name) + if(NOT DEFINED "CPM_DECLARATION_${Name}") + set("CPM_DECLARATION_${Name}" "${ARGN}") + endif() +endmacro() + +function(cpm_add_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN false ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} "# ${Name}\nCPMDeclarePackage(${Name}\n${PRETTY_ARGN})\n") + endif() +endfunction() + +function(cpm_add_comment_to_package_lock Name) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + cpm_prettify_package_arguments(PRETTY_ARGN true ${ARGN}) + file(APPEND ${CPM_PACKAGE_LOCK_FILE} + "# ${Name} (unversioned)\n# CPMDeclarePackage(${Name}\n${PRETTY_ARGN}#)\n" + ) + endif() +endfunction() + +# includes the package lock file if it exists and creates a target `cpm-update-package-lock` to +# update it +macro(CPMUsePackageLock file) + if(NOT CPM_DONT_CREATE_PACKAGE_LOCK) + get_filename_component(CPM_ABSOLUTE_PACKAGE_LOCK_PATH ${file} ABSOLUTE) + if(EXISTS ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + include(${CPM_ABSOLUTE_PACKAGE_LOCK_PATH}) + endif() + if(NOT TARGET cpm-update-package-lock) + add_custom_target( + cpm-update-package-lock COMMAND ${CMAKE_COMMAND} -E copy ${CPM_PACKAGE_LOCK_FILE} + ${CPM_ABSOLUTE_PACKAGE_LOCK_PATH} + ) + endif() + set(CPM_PACKAGE_LOCK_ENABLED true) + endif() +endmacro() + +# registers a package that has been added to CPM +function(CPMRegisterPackage PACKAGE VERSION) + list(APPEND CPM_PACKAGES ${PACKAGE}) + set(CPM_PACKAGES + ${CPM_PACKAGES} + CACHE INTERNAL "" + ) + set("CPM_PACKAGE_${PACKAGE}_VERSION" + ${VERSION} + CACHE INTERNAL "" + ) +endfunction() + +# retrieve the current version of the package to ${OUTPUT} +function(CPMGetPackageVersion PACKAGE OUTPUT) + set(${OUTPUT} + "${CPM_PACKAGE_${PACKAGE}_VERSION}" + PARENT_SCOPE + ) +endfunction() + +# declares a package in FetchContent_Declare +function(cpm_declare_fetch PACKAGE) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package not declared (dry run)") + return() + endif() + + FetchContent_Declare(${PACKAGE} ${ARGN}) +endfunction() + +# returns properties for a package previously defined by cpm_declare_fetch +function(cpm_get_fetch_properties PACKAGE) + if(${CPM_DRY_RUN}) + return() + endif() + + set(${PACKAGE}_SOURCE_DIR + "${CPM_PACKAGE_${PACKAGE}_SOURCE_DIR}" + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + "${CPM_PACKAGE_${PACKAGE}_BINARY_DIR}" + PARENT_SCOPE + ) +endfunction() + +function(cpm_store_fetch_properties PACKAGE source_dir binary_dir) + if(${CPM_DRY_RUN}) + return() + endif() + + set(CPM_PACKAGE_${PACKAGE}_SOURCE_DIR + "${source_dir}" + CACHE INTERNAL "" + ) + set(CPM_PACKAGE_${PACKAGE}_BINARY_DIR + "${binary_dir}" + CACHE INTERNAL "" + ) +endfunction() + +# adds a package as a subdirectory if viable, according to provided options +function( + cpm_add_subdirectory + PACKAGE + DOWNLOAD_ONLY + SOURCE_DIR + BINARY_DIR + EXCLUDE + SYSTEM + OPTIONS +) + + if(NOT DOWNLOAD_ONLY AND EXISTS ${SOURCE_DIR}/CMakeLists.txt) + set(addSubdirectoryExtraArgs "") + if(EXCLUDE) + list(APPEND addSubdirectoryExtraArgs EXCLUDE_FROM_ALL) + endif() + if("${SYSTEM}" AND "${CMAKE_VERSION}" VERSION_GREATER_EQUAL "3.25") + # https://cmake.org/cmake/help/latest/prop_dir/SYSTEM.html#prop_dir:SYSTEM + list(APPEND addSubdirectoryExtraArgs SYSTEM) + endif() + if(OPTIONS) + foreach(OPTION ${OPTIONS}) + cpm_parse_option("${OPTION}") + set(${OPTION_KEY} "${OPTION_VALUE}") + endforeach() + endif() + set(CPM_OLD_INDENT "${CPM_INDENT}") + set(CPM_INDENT "${CPM_INDENT} ${PACKAGE}:") + add_subdirectory(${SOURCE_DIR} ${BINARY_DIR} ${addSubdirectoryExtraArgs}) + set(CPM_INDENT "${CPM_OLD_INDENT}") + endif() +endfunction() + +# downloads a previously declared package via FetchContent and exports the variables +# `${PACKAGE}_SOURCE_DIR` and `${PACKAGE}_BINARY_DIR` to the parent scope +function(cpm_fetch_package PACKAGE DOWNLOAD_ONLY populated) + set(${populated} + FALSE + PARENT_SCOPE + ) + if(${CPM_DRY_RUN}) + cpm_message(STATUS "${CPM_INDENT} Package ${PACKAGE} not fetched (dry run)") + return() + endif() + + FetchContent_GetProperties(${PACKAGE}) + + string(TOLOWER "${PACKAGE}" lower_case_name) + + if(NOT ${lower_case_name}_POPULATED) + if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.30.3") + if(DOWNLOAD_ONLY) + # MakeAvailable will call add_subdirectory internally which is not what we want when + # DOWNLOAD_ONLY is set. Populate will only download the dependency without adding it to the + # build + FetchContent_Populate( + ${PACKAGE} + SOURCE_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-src" + BINARY_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-build" + SUBBUILD_DIR "${CPM_FETCHCONTENT_BASE_DIR}/${lower_case_name}-subbuild" + ${ARGN} + ) + else() + FetchContent_MakeAvailable(${PACKAGE}) + endif() + else() + FetchContent_Populate(${PACKAGE}) + endif() + set(${populated} + TRUE + PARENT_SCOPE + ) + endif() + + cpm_store_fetch_properties( + ${CPM_ARGS_NAME} ${${lower_case_name}_SOURCE_DIR} ${${lower_case_name}_BINARY_DIR} + ) + + set(${PACKAGE}_SOURCE_DIR + ${${lower_case_name}_SOURCE_DIR} + PARENT_SCOPE + ) + set(${PACKAGE}_BINARY_DIR + ${${lower_case_name}_BINARY_DIR} + PARENT_SCOPE + ) +endfunction() + +# splits a package option +function(cpm_parse_option OPTION) + string(REGEX MATCH "^[^ ]+" OPTION_KEY "${OPTION}") + string(LENGTH "${OPTION}" OPTION_LENGTH) + string(LENGTH "${OPTION_KEY}" OPTION_KEY_LENGTH) + if(OPTION_KEY_LENGTH STREQUAL OPTION_LENGTH) + # no value for key provided, assume user wants to set option to "ON" + set(OPTION_VALUE "ON") + else() + math(EXPR OPTION_KEY_LENGTH "${OPTION_KEY_LENGTH}+1") + string(SUBSTRING "${OPTION}" "${OPTION_KEY_LENGTH}" "-1" OPTION_VALUE) + string(STRIP "${OPTION_VALUE}" OPTION_VALUE) + endif() + set(OPTION_KEY + "${OPTION_KEY}" + PARENT_SCOPE + ) + set(OPTION_VALUE + "${OPTION_VALUE}" + PARENT_SCOPE + ) +endfunction() + +# guesses the package version from a git tag +function(cpm_get_version_from_git_tag GIT_TAG RESULT) + string(LENGTH ${GIT_TAG} length) + if(length EQUAL 40) + # GIT_TAG is probably a git hash + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + string(REGEX MATCH "v?([0123456789.]*).*" _ ${GIT_TAG}) + set(${RESULT} + ${CMAKE_MATCH_1} + PARENT_SCOPE + ) + endif() +endfunction() + +# guesses if the git tag is a commit hash or an actual tag or a branch name. +function(cpm_is_git_tag_commit_hash GIT_TAG RESULT) + string(LENGTH "${GIT_TAG}" length) + # full hash has 40 characters, and short hash has at least 7 characters. + if(length LESS 7 OR length GREATER 40) + set(${RESULT} + 0 + PARENT_SCOPE + ) + else() + if(${GIT_TAG} MATCHES "^[a-fA-F0-9]+$") + set(${RESULT} + 1 + PARENT_SCOPE + ) + else() + set(${RESULT} + 0 + PARENT_SCOPE + ) + endif() + endif() +endfunction() + +function(cpm_prettify_package_arguments OUT_VAR IS_IN_COMMENT) + set(oneValueArgs + NAME + FORCE + VERSION + GIT_TAG + DOWNLOAD_ONLY + GITHUB_REPOSITORY + GITLAB_REPOSITORY + BITBUCKET_REPOSITORY + GIT_REPOSITORY + SOURCE_DIR + FIND_PACKAGE_ARGUMENTS + NO_CACHE + SYSTEM + GIT_SHALLOW + EXCLUDE_FROM_ALL + SOURCE_SUBDIR + ) + set(multiValueArgs URL OPTIONS DOWNLOAD_COMMAND) + cmake_parse_arguments(CPM_ARGS "" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + foreach(oneArgName ${oneValueArgs}) + if(DEFINED CPM_ARGS_${oneArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + if(${oneArgName} STREQUAL "SOURCE_DIR") + string(REPLACE ${CMAKE_SOURCE_DIR} "\${CMAKE_SOURCE_DIR}" CPM_ARGS_${oneArgName} + ${CPM_ARGS_${oneArgName}} + ) + endif() + string(APPEND PRETTY_OUT_VAR " ${oneArgName} ${CPM_ARGS_${oneArgName}}\n") + endif() + endforeach() + foreach(multiArgName ${multiValueArgs}) + if(DEFINED CPM_ARGS_${multiArgName}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ${multiArgName}\n") + foreach(singleOption ${CPM_ARGS_${multiArgName}}) + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " \"${singleOption}\"\n") + endforeach() + endif() + endforeach() + + if(NOT "${CPM_ARGS_UNPARSED_ARGUMENTS}" STREQUAL "") + if(${IS_IN_COMMENT}) + string(APPEND PRETTY_OUT_VAR "#") + endif() + string(APPEND PRETTY_OUT_VAR " ") + foreach(CPM_ARGS_UNPARSED_ARGUMENT ${CPM_ARGS_UNPARSED_ARGUMENTS}) + string(APPEND PRETTY_OUT_VAR " ${CPM_ARGS_UNPARSED_ARGUMENT}") + endforeach() + string(APPEND PRETTY_OUT_VAR "\n") + endif() + + set(${OUT_VAR} + ${PRETTY_OUT_VAR} + PARENT_SCOPE + ) + +endfunction() diff --git a/cmake/Steam.cmake b/cmake/Steam.cmake new file mode 100644 index 0000000..886dca8 --- /dev/null +++ b/cmake/Steam.cmake @@ -0,0 +1,12 @@ +# Steam Workshop downloads via PxSteamDL. The root CMakeLists includes this file +# unless PX_WORKSHOP_VARIANT is NoWorkshop. Without it the build has no Steam support +# and downloads/steam.cpp is compiled out (PX_HAS_STEAM stays undefined). + +# PxSteamDL builds all of its dependencies from source. Its cacert dependency +# uses a date as VERSION, which CPM rejects as a find_package() name when local +# packages are enabled, so local lookup is switched off for this fetch only. +set(CPM_USE_LOCAL_PACKAGES OFF) +CPMAddPackage("gh:PyXiion/PxSteamDL@0.3.0") +set(CPM_USE_LOCAL_PACKAGES ON) + +set(PX_STEAM_LIBS PxSteamDL::pxsteamdl) diff --git a/justfile b/justfile index 9f68cbf..9e8998f 100644 --- a/justfile +++ b/justfile @@ -1,82 +1,70 @@ -ruff_config := "--config pyproject.toml" -pytest_opts := "--doctest-modules --no-qt-log" +set windows-shell := ["bash", "-cu"] + +python := if os() == "windows" { "python" } else { "python3" } +app_exe := if os() == "macos" { "pxmodrim.app/Contents/MacOS/pxmodrim" } else { "pxmodrim" } +clang_tidy := env("CLANG_TIDY", "clang-tidy") @default: just --list -build-js: - uv run python scripts/build-inject-js.py +configure *args: + cmake --preset default {{args}} -run: build-js dev-setup - LOGURU_LEVEL=DEBUG uv run python -m pxmodrim +build *args: build-js + [ -f build/default/CMakeCache.txt ] || cmake --preset default + cmake --build --preset default {{args}} -test: build-js dev-setup - QT_QPA_PLATFORM=offscreen uv run pytest {{pytest_opts}} -s +# Build and start the app (Steam browser variant); each variant has its own build directory +run *args: (_run "SteamWorkshop" "default" args) -test-verbose: build-js dev-setup - QT_QPA_PLATFORM=offscreen uv run pytest {{pytest_opts}} -v --tb=short -s +# Build and start the app with the native Workshop browser +run-native *args: (_run "NativeWorkshop" "native" args) -pyright: - uv run python -m pyright -p pyproject.toml src/ tests/ +# Build and start the app as a plain mod sorter (no Workshop) +run-none *args: (_run "NoWorkshop" "none" args) -ruff-fix: - uv run ruff check {{ruff_config}} src/ tests/ scripts/ packaging/ --fix +[private] +_run variant dir *args: build-js + cmake -S . -B build/{{dir}} -G Ninja -DCMAKE_BUILD_TYPE=Debug -DPX_WORKSHOP_VARIANT={{variant}} + cmake --build build/{{dir}} --target pxmodrim + PX_LOG_LEVEL=debug build/{{dir}}/src/app/{{app_exe}} {{args}} -ruff-format-fix: - uv run ruff format {{ruff_config}} src/ tests/ scripts/ packaging/ +test: build + QT_QPA_PLATFORM=offscreen ctest --preset default -ruff-check: - uv run ruff check {{ruff_config}} src/ tests/ scripts/ packaging/ +build-release: build-js + [ -f build/release/CMakeCache.txt ] || cmake --preset release + cmake --build --preset release -ruff-format-check: - uv run ruff format --check {{ruff_config}} src/ tests/ scripts/ packaging/ +build-js: + {{python}} scripts/build-inject-js.py -fix: ruff-fix ruff-format-fix +format: + find src tests \( -name '*.cpp' -o -name '*.hpp' \) -print0 | xargs -0 clang-format -i -check: ruff-check ruff-format-check build-js pyright check-deps +format-check: + find src tests \( -name '*.cpp' -o -name '*.hpp' \) -print0 | xargs -0 clang-format --dry-run --Werror -check-deps: - uv run python scripts/check-deps.py +# Qt adds GCC-only flags that clang rejects, so clang-tidy reads a filtered copy. +lint-db: + [ -f build/default/compile_commands.json ] || cmake --preset default + mkdir -p build/lint + sed 's/ -mno-direct-extern-access//g' build/default/compile_commands.json > build/lint/compile_commands.json -ci: check test +lint *args: lint-db + run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -extra-arg=-Wno-unknown-warning-option {{args}} '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' + +lint-fix: lint-db + run-clang-tidy -clang-tidy-binary {{clang_tidy}} -p build/lint -quiet -fix -extra-arg=-Wno-unknown-warning-option '^{{justfile_directory()}}/(src|tests)/.*\.cpp$' + +check: format-check lint build -dev-setup: - uv venv --allow-existing - uv sync --dev +ci: check test linux-copy-desktop: mkdir -p ~/.local/share/applications - sed "s|Icon=pxmodrim|Icon={{justfile_directory()}}/src/pxmodrim/ui/assets/logo.svg|" packaging/linux/pxmodrim.desktop > ~/.local/share/applications/pxmodrim.desktop + sed "s|Icon=pxmodrim|Icon={{justfile_directory()}}/src/ui/assets/logo.svg|" packaging/linux/pxmodrim.desktop > ~/.local/share/applications/pxmodrim.desktop kbuildsycoca6 --noincremental -bench name="": - #!/usr/bin/env python3 - import glob, subprocess, sys - from pathlib import Path - root = Path("{{justfile_directory()}}") / "tests" / "benchmarks" - scripts = sorted(glob.glob(str(root / "bench_*.py"))) - name = "{{name}}" - if name and name != "all": - scripts = [s for s in scripts if name in s] - if not scripts: - print("no benchmark found"); sys.exit(1) - for s in scripts: - print(f" === {Path(s).stem} ===") - subprocess.run(["uv", "run", "python", s]) - -build *args: build-js - uv run python packaging/build.py {{args}} - -build-release *args: build-js - uv run python packaging/build.py --release {{args}} - -build-release-bundled *args: build-js - uv run python packaging/build.py --release --bundle-qt {{args}} - -build-clean: - rm -rf build/ dist/ *.build/ *.dist/ *.onefile-build/ - clean: - rm -rf build/ dist/ *.egg-info - rm -rf .pytest_cache .mypy_cache .ruff_cache - find . -path ./rimsort-original -prune -o -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + rm -rf build dist diff --git a/packaging/build.py b/packaging/build.py deleted file mode 100644 index 4b573bf..0000000 --- a/packaging/build.py +++ /dev/null @@ -1,678 +0,0 @@ -from __future__ import annotations - -import argparse -import os -import platform -import shutil -import subprocess -import sys -import tomllib -from pathlib import Path -from tempfile import TemporaryDirectory - - -def get_version(project_root: Path) -> str: - tag = os.environ.get("GITHUB_REF_NAME", "") - if tag.startswith("v") and len(tag) > 1 and tag[1].isdigit(): - return tag[1:] - pyproject = project_root / "pyproject.toml" - return tomllib.loads(pyproject.read_text("utf-8"))["project"]["version"] - - -def debian_architecture(machine: str | None = None) -> str: - if machine is None: - machine = platform.machine() - architectures = {"x86_64": "amd64", "aarch64": "arm64"} - try: - return architectures[machine.lower()] - except KeyError: - raise RuntimeError( - f"Unsupported Debian package architecture: {machine}" - ) from None - - -_WITHOUT_WEBVIEW_BROWSER = [ - ( - "--nofollow-import-to=pxmodrim.ui.plugins.steam_workshop," - "PySide6.QtWebEngine*,PySide6.QtWebChannel" - ), - "--noinclude-data-files=pxmodrim/ui/plugins/steam_workshop", - "--noinclude-data-files=PySide6/qml/QtWeb*/*", - "--noinclude-data-files=*qtwebengine*", - "--noinclude-dlls=PySide6/qml/QtWeb*/*", - "--noinclude-dlls=*WebEngine*", - "--noinclude-dlls=*WebChannel*", -] -_WITHOUT_NATIVE_BROWSER = [ - "--nofollow-import-to=pxmodrim.ui.plugins.workshop", - "--noinclude-data-files=pxmodrim/ui/plugins/workshop", -] -_WITHOUT_DOWNLOADER = [ - ( - "--nofollow-import-to=pxsteamdl,pxmodrim.core.downloads.steam," - "pxmodrim.ui.plugins.downloads" - ), - "--noinclude-data-files=pxmodrim/ui/plugins/downloads", -] - - -def _variant_exclusions(workshop: str) -> list[str]: - """Nuitka options that leave out what the Workshop variant does not ship.""" - match workshop: - case "SteamWorkshop": - return _WITHOUT_NATIVE_BROWSER - case "NativeWorkshop": - return _WITHOUT_WEBVIEW_BROWSER - case "NoWorkshop": - return [ - *_WITHOUT_WEBVIEW_BROWSER, - *_WITHOUT_NATIVE_BROWSER, - *_WITHOUT_DOWNLOADER, - ] - raise ValueError(f"Unknown Workshop variant: {workshop}") - - -def get_standalone_args( - release: bool = False, workshop: str = "NativeWorkshop" -) -> list[str]: - project_root = Path(__file__).parent.parent - - args = [ - sys.executable, - "-m", - "nuitka", - "--standalone", - "--enable-plugin=pyside6", - "--nofollow-import-to=pytest,pygments", - "--include-package-data=pxmodrim", - f"--output-dir={project_root / 'dist'}", - "--assume-yes-for-downloads", - str(project_root / "packaging" / "entrypoint.py"), - ] - - args.append("--include-qt-plugins=qml") - - args.extend(_variant_exclusions(workshop)) - - if release: - args.extend( - [ - "--lto=yes", - "--python-flag=no_asserts", - "--python-flag=no_docstrings", - ] - ) - - args.extend( - [ - "--noinclude-setuptools-mode=nofollow", - "--noinclude-pytest-mode=nofollow", - "--noinclude-unittest-mode=nofollow", - "--noinclude-default-mode=nofollow", - "--nowarn-mnemonic=unwanted-module", - ] - ) - - system = platform.system() - version = get_version(project_root) - if system == "Windows": - args.extend( - [ - "--windows-icon-from-ico=packaging/logo.ico", - "--windows-company-name=PxModRim", - "--windows-product-name=PxModRim", - f"--windows-product-version={version}", - ] - ) - elif system == "Darwin": - logo_icns = project_root / "packaging" / "logo.icns" - args.extend( - [ - "--macos-create-app-bundle", - "--macos-app-name=PxModRim", - f"--macos-app-version={version}", - "--macos-signed-app-name=com.github.PyXiion.PxModRim", - f"--macos-app-icon={logo_icns}", - ] - ) - - return args - - -def _clean_debug_artifacts(base_dir: Path) -> None: - for obj_dir in base_dir.rglob("objects-RelWithDebInfo"): - shutil.rmtree(obj_dir) - print(f"Removed {obj_dir}") - - -def clean_qml_debug_artifacts() -> None: - for qml_dir in Path(".venv").rglob("**/PySide6/Qt/qml"): - _clean_debug_artifacts(qml_dir) - - -def copy_missing_libs(project_root: Path) -> None: - dist_dir = project_root / "dist" / "entrypoint.dist" - - qml_dir = dist_dir / "PySide6" / "qml" - if qml_dir.exists(): - _clean_debug_artifacts(qml_dir) - - venv_qml = None - for pattern in [ - ".venv/**/PySide6/Qt/qml", - "venv/**/PySide6/Qt/qml", - ]: - matches = list(project_root.glob(pattern)) - if matches: - venv_qml = matches[0] - break - - if not venv_qml: - print("Warning: PySide6 QML not found in venv, skipping") - return - - venv_lib = venv_qml.parent / "lib" - - required_libs = [ - "libQt6QmlModels.so.6", - "libQt6QuickTemplates2.so.6", - "libQt6QuickControls2.so.6", - "libQt6QuickControls2Impl.so.6", - "libQt6QuickLayouts.so.6", - ] - - for lib_name in required_libs: - src = venv_lib / lib_name - dst = dist_dir / lib_name - if src.exists() and not dst.exists(): - shutil.copy2(src, dst) - print(f"Copied {lib_name}") - - -def normalize_executable(dist_dir: Path, system: str | None = None) -> Path: - """Normalize the Nuitka entrypoint executable for its platform.""" - if system is None: - system = platform.system() - - if system == "Windows": - output_name = "PxModRim.exe" - elif system == "Darwin": - output_name = "entrypoint" - else: - output_name = "PxModRim" - - final_binary = dist_dir / output_name - - if final_binary.is_file(): - return final_binary - - candidates = ( - ["entrypoint.exe", "entrypoint.bin", "entrypoint"] - if system == "Windows" - else ["entrypoint", "entrypoint.bin", "entrypoint.exe"] - ) - - for candidate_name in candidates: - candidate_path = dist_dir / candidate_name - if candidate_path.is_file(): - candidate_path.rename(final_binary) - print(f"Renamed {candidate_path.name} to {final_binary.name}") - return final_binary - - raise FileNotFoundError( - f"Executable not found in {dist_dir}; looked for '{output_name}' " - f"and candidates {candidates}" - ) - - -def create_appimage(project_root: Path, release: bool = False) -> None: - dist_dir = project_root / "dist" / "entrypoint.dist" - app_dir = project_root / "dist" / "PxModRim.AppDir" - - if app_dir.exists(): - shutil.rmtree(app_dir) - - shutil.copytree(dist_dir, app_dir) - - normalize_executable(app_dir, system="Linux") - - desktop_content = """[Desktop Entry] -Type=Application -Name=PxModRim -Comment=Mod manager for RimWorld -Icon=pxmodrim -Exec=PxModRim -Categories=Game; -StartupWMClass=PxModRim -Terminal=false -""" - (app_dir / "pxmodrim.desktop").write_text(desktop_content) - - icon_src = project_root / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg" - shutil.copy(icon_src, app_dir / "pxmodrim.svg") - - run_script = """#!/bin/bash -HERE="$(dirname "$(readlink -f "$0")")" -exec "$HERE/PxModRim" "$@" -""" - run_file = app_dir / "AppRun" - run_file.write_text(run_script) - run_file.chmod(0o755) - - appimagetool = shutil.which("appimagetool") - - if not appimagetool: - if release: - raise RuntimeError("appimagetool is required to create the AppImage") - print("Warning: appimagetool not found, skipping AppImage creation") - print("Install from: https://github.com/AppImage/AppImageKit/releases") - return - - output = project_root / "dist" / "PxModRim-x86_64.AppImage" - subprocess.run([appimagetool, str(app_dir), str(output)], check=True) - print(f"Created {output}") - - -def create_deb(project_root: Path) -> Path | None: - dist_dir = project_root / "dist" / "entrypoint.dist" - if not dist_dir.exists(): - print("Warning: entrypoint.dist not found, skipping .deb creation") - return None - - dpkg_deb = shutil.which("dpkg-deb") - if not dpkg_deb: - raise RuntimeError("dpkg-deb is required to create the Debian package") - - version = get_version(project_root) - architecture = debian_architecture() - output = project_root / "dist" / f"pxmodrim_{version}_{architecture}.deb" - staging_dir = project_root / "dist" / "deb_staging" - if staging_dir.exists(): - shutil.rmtree(staging_dir) - - debian_dir = staging_dir / "DEBIAN" - opt_dir = staging_dir / "opt" / "pxmodrim" - bin_dir = staging_dir / "usr" / "bin" - apps_dir = staging_dir / "usr" / "share" / "applications" - icons_dir = ( - staging_dir / "usr" / "share" / "icons" / "hicolor" / "scalable" / "apps" - ) - - debian_dir.mkdir(parents=True, exist_ok=True) - bin_dir.mkdir(parents=True, exist_ok=True) - apps_dir.mkdir(parents=True, exist_ok=True) - icons_dir.mkdir(parents=True, exist_ok=True) - - shutil.copytree(dist_dir, opt_dir) - (bin_dir / "pxmodrim").symlink_to("/opt/pxmodrim/PxModRim") - shutil.copy2( - project_root / "packaging" / "linux" / "pxmodrim.desktop", - apps_dir / "pxmodrim.desktop", - ) - shutil.copy2( - project_root / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg", - icons_dir / "pxmodrim.svg", - ) - - exe_file = opt_dir / "PxModRim" - if exe_file.exists(): - exe_file.chmod(0o755) - - installed_size = ( - sum(f.stat().st_size for f in staging_dir.rglob("*") if f.is_file()) // 1024 - ) - control_content = f"""Package: pxmodrim -Version: {version} -Section: games -Priority: optional -Architecture: {architecture} -Installed-Size: {installed_size} -Maintainer: PyXiion -Homepage: https://github.com/PyXiion/PxModRim -Description: Mod manager for RimWorld - PxModRim is a fast and modern mod manager for RimWorld. -""" - (debian_dir / "control").write_text(control_content, encoding="utf-8") - - subprocess.run( - [dpkg_deb, "--build", "--root-owner-group", str(staging_dir), str(output)], - check=True, - ) - shutil.rmtree(staging_dir) - print(f"Created {output}") - return output - - -def create_rpm(project_root: Path, release: bool = False) -> Path | None: - rpmbuild = shutil.which("rpmbuild") - if not rpmbuild: - if release: - raise RuntimeError("rpmbuild is required to create the RPM") - print("Warning: rpmbuild not found, skipping RPM creation") - return None - - version = get_version(project_root) - dist_dir = project_root / "dist" / "entrypoint.dist" - if not dist_dir.exists(): - print("Warning: entrypoint.dist not found, skipping RPM creation") - return None - - top_dir = project_root / "dist" / "rpmbuild" - if top_dir.exists(): - shutil.rmtree(top_dir) - for sub in ("BUILD", "RPMS", "SOURCES", "SPECS", "SRPMS"): - (top_dir / sub).mkdir(parents=True, exist_ok=True) - - spec_file = project_root / "packaging" / "linux" / "pxmodrim.spec" - cmd = [ - rpmbuild, - "-bb", - f"--define=_topdir {top_dir.resolve()}", - f"--define=project_root {project_root.resolve()}", - f"--define=version {version}", - str(spec_file.resolve()), - ] - subprocess.run(cmd, check=True) - - rpm_files = list((top_dir / "RPMS").rglob("*.rpm")) - if not rpm_files: - print("Warning: No RPM file was generated") - return None - - output = project_root / "dist" / rpm_files[0].name - shutil.copy2(rpm_files[0], output) - shutil.rmtree(top_dir) - print(f"Created {output}") - return output - - -def create_nsis_installer(project_root: Path) -> Path | None: - makensis = shutil.which("makensis") - nsis_dir = os.environ.get("NSIS_DIR") - if nsis_dir: - configured_nsis = Path(nsis_dir) / "makensis.exe" - if configured_nsis.exists(): - makensis = str(configured_nsis) - if not makensis and platform.system() == "Windows": - default_nsis = Path("C:/Program Files (x86)/NSIS/makensis.exe") - if default_nsis.exists(): - makensis = str(default_nsis) - - if not makensis: - print("Warning: makensis not found, skipping NSIS installer creation") - return None - - version = get_version(project_root) - dist_dir = project_root / "dist" / "entrypoint.dist" - output = project_root / "dist" / "PxModRim-Setup.exe" - nsi_file = project_root / "packaging" / "windows" / "installer.nsi" - - cmd = [ - makensis, - f"/DVERSION={version}", - f"/DDIST_DIR={dist_dir.resolve()}", - f"/DOUTPUT_FILE={output.resolve()}", - str(nsi_file.resolve()), - ] - subprocess.run(cmd, check=True) - print(f"Created {output}") - return output - - -def create_macos_bundle(project_root: Path, release: bool = False) -> Path | None: - version = get_version(project_root) - dist_path = project_root / "dist" - - app_dir = dist_path / "PxModRim.app" - entry_app = dist_path / "entrypoint.app" - if not app_dir.exists() and entry_app.exists(): - entry_app.rename(app_dir) - - if not app_dir.exists(): - print("Warning: PxModRim.app not found, skipping macOS bundle packaging") - return None - macos_dir = app_dir / "Contents" / "MacOS" - if not macos_dir.exists(): - raise FileNotFoundError(f"Missing Contents/MacOS directory in {app_dir}") - - final_binary = normalize_executable(macos_dir, system="Darwin") - - plist_file = app_dir / "Contents" / "Info.plist" - if plist_file.exists(): - import plistlib - - with open(plist_file, "rb") as f: - pl = plistlib.load(f) - pl["CFBundleIdentifier"] = "com.github.PyXiion.PxModRim" - pl["CFBundleName"] = "PxModRim" - pl["CFBundleDisplayName"] = "PxModRim" - pl["CFBundleVersion"] = version - pl["CFBundleShortVersionString"] = version - pl["CFBundleExecutable"] = final_binary.name - pl["CFBundlePackageType"] = "APPL" - pl["NSHighResolutionCapable"] = True - pl["LSMinimumSystemVersion"] = "11.0" - with open(plist_file, "wb") as f: - plistlib.dump(pl, f) - print(f"Updated Info.plist bundle metadata (executable={final_binary.name})") - - subprocess.run( - ["codesign", "--force", "--deep", "--sign", "-", str(app_dir)], - check=True, - ) - - zip_output = dist_path / "PxModRim-macOS" - shutil.make_archive( - str(zip_output), "zip", root_dir=str(dist_path), base_dir="PxModRim.app" - ) - print(f"Created {zip_output}.zip") - - hdiutil = shutil.which("hdiutil") - if not hdiutil: - if release: - raise RuntimeError("hdiutil is required to create the macOS DMG") - print("Warning: hdiutil not found, skipping DMG creation") - return app_dir - - dmg_output = dist_path / "PxModRim-macOS.dmg" - if dmg_output.exists(): - dmg_output.unlink() - subprocess.run( - [ - hdiutil, - "create", - "-volname", - "PxModRim", - "-srcfolder", - str(app_dir), - "-ov", - "-format", - "UDZO", - str(dmg_output), - ], - check=True, - ) - print(f"Created {dmg_output}") - - return app_dir - - -def strip_unused_qml_modules(dist_dir: Path, workshop: str = "NativeWorkshop") -> None: - """Remove QML modules unused by the selected Workshop browser.""" - qml_root = dist_dir / "PySide6" / "qml" - if not qml_root.is_dir(): - return - keep = {"Qt", "QtQml", "QtQuick"} - if workshop == "SteamWorkshop": - keep.update({"QtWebChannel", "QtWebEngine"}) - removed = 0 - for entry in sorted(qml_root.iterdir()): - if entry.is_dir() and entry.name not in keep: - size = sum(f.stat().st_size for f in entry.rglob("*") if f.is_file()) - shutil.rmtree(entry) - removed += size - if size > 1024 * 1024: - print(f" Removed {entry.name} ({size // (1024 * 1024)} MB)") - else: - print(f" Removed {entry.name} ({size // 1024} KB)") - if removed: - print(f" Freed {removed // (1024 * 1024)} MB by removing unused QML modules") - - -def strip_qt_translations(dist_dir: Path) -> None: - """Remove all Qt .qm files except English ones.""" - removed = 0 - for f in dist_dir.glob("*.qm"): - base = f.stem.lower() - if any(base.endswith(suf) for suf in ("_en", "_en_us", "_en_gb")): - continue - removed += f.stat().st_size - f.unlink() - if removed: - print(f" Removed non-English .qm files ({removed // 1024} KB)") - - -def strip_qt_devtools(dist_dir: Path) -> None: - """Remove QtWebEngine DevTools resources (~12 MB).""" - pak = dist_dir / "qtwebengine_devtools_resources.pak" - if pak.exists(): - sz = pak.stat().st_size - pak.unlink() - print( - f" Removed qtwebengine_devtools_resources.pak ({sz // (1024 * 1024)} MB)" - ) - - -def strip_qt_locales(dist_dir: Path) -> None: - """Keep only en-US WebEngine locale, remove the other 50+.""" - locale_dir = dist_dir / "qtwebengine_locales" - if not locale_dir.is_dir(): - return - kept = en_count = 0 - for f in locale_dir.iterdir(): - if f.suffix != ".pak": - continue - if f.stem in ("en-US", "en-GB", "en"): - en_count += 1 - continue - f.unlink() - kept += 1 - print(f" Kept {en_count} English locale(s), removed {kept} other locales") - - -def strip_bundled_qt(dist_dir: Path) -> None: - """Remove bundled Qt shared libs so the app uses system Qt at runtime.""" - patterns = [ - "libQt6*.so*", - "libicu*.so*", - "icudtl.dat", - ] - total = 0 - for pattern in patterns: - for f in dist_dir.glob(pattern): - if f.is_file(): - size = f.stat().st_size - f.unlink() - total += size - print(f" Removed {f.name} ({size // 1024 // 1024}MB)") - print(f" Freed {total // 1024 // 1024}MB by removing bundled Qt libs") - - -def _get_dir_size(path: Path) -> int: - total = 0 - for entry in path.rglob("*"): - if entry.is_file(): - total += entry.stat().st_size - return total - - -def main() -> None: - parser = argparse.ArgumentParser(description="Build a standalone PxModRim package.") - parser.add_argument("--release", action="store_true") - parser.add_argument("--bundle-qt", action="store_true") - parser.add_argument( - "--workshop", - choices=("NoWorkshop", "SteamWorkshop", "NativeWorkshop"), - default="NativeWorkshop", - ) - options = parser.parse_args() - release = options.release - bundle_qt = options.bundle_qt - workshop = options.workshop - project_root = Path(__file__).parent.parent - system = platform.system() - - print("Step 0: Cleaning QML debug artifacts...") - clean_qml_debug_artifacts() - - print( - f"Step 1: Building standalone " - f"(release={release}, bundle_qt={bundle_qt}, workshop={workshop})..." - ) - with TemporaryDirectory(prefix="pxmodrim-workshop-") as profile_dir: - manifest = Path(profile_dir) / "workshop-variant.txt" - manifest.write_text(workshop, encoding="utf-8") - args = get_standalone_args(release=release, workshop=workshop) - args.append(f"--include-data-files={manifest}=pxmodrim/workshop-variant.txt") - print(f"Running: {' '.join(args)}") - - result = subprocess.run(args, check=False) - if result.returncode != 0: - sys.exit(result.returncode) - - dist_dir = project_root / "dist" / "entrypoint.dist" - if system == "Darwin": - for candidate in [ - project_root / "dist" / "PxModRim.app" / "Contents" / "MacOS", - project_root / "dist" / "entrypoint.app" / "Contents" / "MacOS", - ]: - if candidate.exists(): - dist_dir = candidate - break - - print("\nStep 2a: Stripping non-English WebEngine locales...") - strip_qt_locales(dist_dir) - - print("\nStep 2b: Stripping unused QML modules...") - strip_unused_qml_modules(dist_dir, workshop=workshop) - - print("\nStep 2c: Stripping Qt translations...") - strip_qt_translations(dist_dir) - - print("\nStep 2d: Stripping QtWebEngine DevTools...") - strip_qt_devtools(dist_dir) - - if bundle_qt and system == "Linux": - print("\nStep 2e: Copying missing Qt libraries...") - copy_missing_libs(project_root) - elif not bundle_qt and system == "Linux": - print("\nStep 2e: Stripping bundled Qt libs (using system Qt)...") - strip_bundled_qt(dist_dir) - - final_binary = normalize_executable(dist_dir, system=system) - if system == "Linux": - if bundle_qt: - print("\nStep 3: Creating Linux packages (AppImage, deb, rpm)...") - create_appimage(project_root, release=release) - create_deb(project_root) - create_rpm(project_root, release=release) - else: - print( - "\nStep 3: Skipping Linux packages (system Qt mode, not self-contained)" - ) - elif system == "Windows": - print("\nStep 3: Creating Windows NSIS installer...") - create_nsis_installer(project_root) - elif system == "Darwin": - print("\nStep 3: Packaging macOS .app bundle...") - create_macos_bundle(project_root, release=release) - - print(f"\nBuild complete! Output: {dist_dir}") - print(f"Run with: {final_binary}") - - size = _get_dir_size(dist_dir) - print(f"Build size: {size // (1024 * 1024)} MB ({size:,} bytes)") - - -if __name__ == "__main__": - main() diff --git a/packaging/entrypoint.py b/packaging/entrypoint.py deleted file mode 100644 index 1563f11..0000000 --- a/packaging/entrypoint.py +++ /dev/null @@ -1,4 +0,0 @@ -from pxmodrim.__main__ import main - -if __name__ == "__main__": - main() diff --git a/packaging/flatpak/com.github.PyXiion.PxModRim.yaml b/packaging/flatpak/com.github.PyXiion.PxModRim.yaml index 282e8a7..3901439 100644 --- a/packaging/flatpak/com.github.PyXiion.PxModRim.yaml +++ b/packaging/flatpak/com.github.PyXiion.PxModRim.yaml @@ -24,20 +24,20 @@ modules: buildsystem: simple build-commands: - install -d /app/lib/pxmodrim - - cp -a entrypoint.dist/* /app/lib/pxmodrim/ + - cp -a usr/. /app/lib/pxmodrim/ - install -d /app/bin - - ln -s /app/lib/pxmodrim/PxModRim /app/bin/PxModRim + - ln -s /app/lib/pxmodrim/bin/pxmodrim /app/bin/PxModRim - install -Dm644 com.github.PyXiion.PxModRim.desktop /app/share/applications/com.github.PyXiion.PxModRim.desktop - install -Dm644 com.github.PyXiion.PxModRim.svg /app/share/icons/hicolor/scalable/apps/com.github.PyXiion.PxModRim.svg - install -Dm644 com.github.PyXiion.PxModRim.metainfo.xml /app/share/metainfo/com.github.PyXiion.PxModRim.metainfo.xml sources: - type: dir - path: ../../dist/entrypoint.dist - dest: entrypoint.dist + path: ../../dist/usr + dest: usr - type: file path: com.github.PyXiion.PxModRim.desktop - type: file - path: ../../src/pxmodrim/ui/assets/logo.svg + path: ../../src/ui/assets/logo.svg dest-filename: com.github.PyXiion.PxModRim.svg - type: file path: com.github.PyXiion.PxModRim.metainfo.xml diff --git a/packaging/linux/pxmodrim.spec b/packaging/linux/pxmodrim.spec index c09b32a..d9e4e99 100644 --- a/packaging/linux/pxmodrim.spec +++ b/packaging/linux/pxmodrim.spec @@ -17,10 +17,10 @@ mkdir -p %{buildroot}/usr/bin mkdir -p %{buildroot}/usr/share/applications mkdir -p %{buildroot}/usr/share/icons/hicolor/scalable/apps -cp -a %{project_root}/dist/entrypoint.dist/* %{buildroot}/opt/pxmodrim/ -ln -s /opt/pxmodrim/PxModRim %{buildroot}/usr/bin/pxmodrim +cp -a %{project_root}/dist/AppDir/usr/. %{buildroot}/opt/pxmodrim/ +ln -s /opt/pxmodrim/bin/pxmodrim %{buildroot}/usr/bin/pxmodrim install -m 644 %{project_root}/packaging/linux/pxmodrim.desktop %{buildroot}/usr/share/applications/pxmodrim.desktop -install -m 644 %{project_root}/src/pxmodrim/ui/assets/logo.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/pxmodrim.svg +install -m 644 %{project_root}/src/ui/assets/logo.svg %{buildroot}/usr/share/icons/hicolor/scalable/apps/pxmodrim.svg %files /opt/pxmodrim diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 2100a0d..ae97850 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -13,7 +13,7 @@ !endif !ifndef DIST_DIR - !define DIST_DIR "..\..\dist\entrypoint.dist" + !define DIST_DIR "..\..\dist\PxModRim\bin" !endif !ifndef OUTPUT_FILE diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index cebddd7..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,101 +0,0 @@ -[project] -name = "pxmodrim" -description = "A mod manager for RimWorld." -license = "GPL-3.0-or-later" -license-files = ["LICENSE", "NOTICE"] -version = "0.3.1" -requires-python = "==3.12.*" -dependencies = [ - "aiosqlite>=0.22.1", - "httpx>=0.28.1", - "loguru>=0.7.3", - "lxml>=6.1.1", - "msgspec>=0.21.1", - "psutil>=7.2.2", - "pyside6>=6.11.1", - "qasync>=0.28.0", - "toposort>=1.10", - "ttimer>=0.0.2", -] - -[project.optional-dependencies] -# Steam Workshop downloads; without it the app runs as a plain mod sorter. -steam = ["pxsteamdl>=0.3.0"] - -[project.gui-scripts] -pxmodrim = "pxmodrim.__main__:main" - -[dependency-groups] -dev = [ - "pxsteamdl>=0.3.0", - "nuitka>=2.6.7", - "pydeps>=3.0.7", - "pyright>=1.1.411", - "pytest>=9.1.1", - "pytest-asyncio>=1.4.0", - "pytest-qt>=4.5.0", - "pytest-xvfb>=3.1.1", - "ruff>=0.16.0", -] - -[tool.uv] -package = true -environments = [ - "sys_platform == 'linux'", - "sys_platform == 'win32'", - "sys_platform == 'darwin'", -] - -[tool.uv.sources] -pxsteamdl = { git = "https://github.com/PyXiion/PxSteamDL" } - -[tool.ruff] -line-length = 88 -indent-width = 4 -exclude = ["rimsort-original", "companion-mods"] - -[tool.ruff.format] -quote-style = "double" - -[tool.ruff.lint] -extend-select = [ - "I", # isort - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "UP", # pyupgrade - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "SIM", # flake8-simplify - "RET", # flake8-return -] - -[tool.pytest.ini_options] -addopts = [ - "--import-mode=importlib", - "--no-qt-log", -] -asyncio_mode = "auto" -pythonpath = 'src' -testpaths = ['tests'] - -[tool.pyright] -venvPath = "." -venv = ".venv" -pythonPlatform = "All" -typeCheckingMode = "standard" -useLibraryCodeForTypes = true -exclude = ['rimsort-original'] - -[tool.ruff.lint.per-file-ignores] -"tests/**/*.py" = [ - "S101", # allowing assert - "D", # no docs - "F841", # unused local vars -] -[tool.bandit] -targets = ["src"] -exclude_dirs = ['tests'] - -[tool.bandit.assert_used] -skips = ['tests/**/*.py'] diff --git a/scripts/build-inject-js.py b/scripts/build-inject-js.py index 44d9ed2..de108f3 100644 --- a/scripts/build-inject-js.py +++ b/scripts/build-inject-js.py @@ -6,16 +6,8 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent -TS_DIR = PROJECT_ROOT / "src" / "pxmodrim" / "ui" / "plugins" / "steam_workshop" / "ts" -OUT_FILE = ( - PROJECT_ROOT - / "src" - / "pxmodrim" - / "ui" - / "plugins" - / "steam_workshop" - / "inject.js" -) +TS_DIR = PROJECT_ROOT / "src" / "ui" / "plugins" / "steam_workshop" / "ts" +OUT_FILE = TS_DIR.parent / "inject.js" def main() -> int: diff --git a/scripts/check-deps.py b/scripts/check-deps.py deleted file mode 100644 index 45ec865..0000000 --- a/scripts/check-deps.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Check layer imports and circular dependencies using pydeps. - -Usage: uv run python scripts/check-deps.py - -Layer rules (bottom→top): - core/ — domain logic; never imports ui/ - ui/ — presentation layer; may import core/ - -Sub-layer rules enforce dependency ordering within each top-level package. -""" - -from __future__ import annotations - -import sys - -from pydeps import py2depgraph, target -from pydeps.configs import Config - -SRC = "src/pxmodrim" - -ENTRYPOINTS = {"__main__", "_app"} - -GROUPS: dict[str, set[str]] = { - "core.foundation": { - "core.constants", - "core.events", - "core.structures", - "core.utils", - "core.xml", - "core.loading", - "core.profiler", - "core.plugin", - "core.migrator", - }, - "core.models": {"core.models"}, - "core.msgspec_hooks": {"core.msgspec_hooks"}, - "core.sort": {"core.sort"}, - "core.checker": {"core.checker"}, - "core.config": {"core.config"}, - "core.plugin_config": {"core.plugin_config"}, - "core.context": {"core.context"}, - "core.mods_config": {"core.mods_config"}, - "core.providers": {"core.providers"}, - "core.services": {"core.services"}, - "core.mod_service": {"core.mod_service"}, - "core.downloads": {"core.downloads"}, - "core.workshop": {"core.workshop"}, - "core.organizer": {"core.organizer"}, - "core.support": {"core.support"}, - "ui.progress": {"ui.progress"}, - "ui.context": {"ui.context"}, - "ui.navigation": {"ui.navigation"}, - "ui.settings_section": {"ui.settings_section"}, - "ui.theme": {"ui.theme"}, - "ui.components": {"ui.components"}, - "ui.models": {"ui.models"}, - "ui.ui_prefs": {"ui.ui_prefs"}, - "ui.config": {"ui.config"}, - "ui.mod_selection": {"ui.mod_selection"}, - "ui.panels": {"ui.panels"}, - "ui.plugins": {"ui.plugins"}, - "ui.views": {"ui.views"}, - "ui.window": {"ui.window"}, -} - -ALLOWED: dict[str, set[str]] = { - "core": set(), - "ui": {"core"}, - "core.foundation": set(), - "core.models": {"core.foundation"}, - "core.msgspec_hooks": {"core.foundation", "core.models"}, - "core.sort": { - "core.foundation", - "core.models", - "core.msgspec_hooks", - "core.config", - }, - "core.checker": {"core.foundation", "core.models", "core.sort", "core.config"}, - "core.config": {"core.foundation", "core.msgspec_hooks", "core.sort"}, - "core.plugin_config": {"core.foundation", "core.config"}, - "core.context": {"core"}, - "core.mods_config": {"core.foundation", "core.models"}, - "core.providers": { - "core.foundation", - "core.models", - "core.config", - "core.services", - }, - "core.services": { - "core.foundation", - "core.models", - "core.checker", - "core.sort", - "core.context", - "core.config", - "core.mods_config", - }, - "core.mod_service": { - "core.foundation", - "core.models", - "core.context", - "core.providers", - "core.services", - "core.mods_config", - }, - "core.downloads": { - "core.plugin_config", - "core.foundation", - "core.models", - "core.config", - "core.context", - }, - "core.workshop": { - "core.plugin_config", - "core.foundation", - "core.models", - "core.config", - "core.context", - "core.downloads", - "core.mod_service", - }, - "core.organizer": { - "core.foundation", - "core.models", - "core.config", - "core.context", - }, - "core.support": { - "core.foundation", - "core.models", - "core.config", - "core.context", - }, - "ui.progress": set(), - "ui.context": {"ui.ui_prefs", "ui.settings_section"}, - "ui.settings_section": {"core"}, - "ui.theme": {"ui.components"}, - "ui.components": {"ui.theme"}, - "ui.models": {"ui.theme"}, - "ui.ui_prefs": set(), - "ui.navigation": set(), - "ui.config": {"core", "ui.ui_prefs"}, - "ui.mod_selection": {"ui.panels"}, - "ui.plugins": { - "core", - "ui.theme", - "ui.models", - "ui.context", - "ui.components", - "ui.views", - "ui.panels", - "ui.mod_selection", - "ui.navigation", - "ui.settings_section", - }, - "ui.panels": { - "ui.settings_section", - "ui.theme", - "ui.components", - "ui.models", - "ui.config", - "ui.ui_prefs", - "ui.plugins", - }, - "ui.views": { - "ui.theme", - "ui.context", - "ui.components", - "ui.models", - "ui.panels", - "ui.config", - "ui.ui_prefs", - "ui.plugins", - }, - "ui.window": { - "ui.theme", - "ui.context", - "ui.components", - "ui.models", - "ui.panels", - "ui.views", - "ui.config", - "ui.ui_prefs", - "ui.mod_selection", - "ui.navigation", - }, -} - -_PREFIX_TO_GROUP: dict[str, str] = {} -for _group, _members in GROUPS.items(): - for _member in _members: - _PREFIX_TO_GROUP[f"pxmodrim.{_member}"] = _group - - -def _layer(modname: str) -> str | None: - if not modname.startswith("pxmodrim."): - return None - parts = modname.split(".") - if len(parts) < 3: - return None - top = parts[1] - if top in ENTRYPOINTS: - return None - if parts[2] == "__init__": - return None - prefix = f"pxmodrim.{top}.{parts[2]}" - return _PREFIX_TO_GROUP.get(prefix, top) - - -def _is_allowed(src_group: str, tgt_group: str) -> bool: - own = ALLOWED.get(src_group, set()) - parent = ALLOWED.get(src_group.split(".")[0], set()) - effective = own | parent - if tgt_group in effective: - return True - return tgt_group.split(".")[0] in effective - - -def main() -> int: - cfg = Config() - cfg.fname = SRC - cfg.no_show = True - cfg.no_output = True - cfg.max_bacon = sys.maxsize - cfg.no_dot = True - cfg.max_module_depth = 3 - - trgt = target.Target(cfg.fname) - dg = py2depgraph.py2dep(trgt, **cfg.__dict__) - - has_error = False - - cycles = dg.find_import_cycles() - if cycles: - print(f"Found {len(cycles)} cyclic import(s):") - for cycle in cycles: - parts = [s.name for s in cycle] - print(" ", " → ".join(parts)) - has_error = True - - violations: list[tuple[str, str, str]] = [] - for modname, src in dg.sources.items(): - src_group = _layer(modname) - if src_group is None: - continue - for imp in src.imports: - tgt_group = _layer(imp) - if tgt_group is None or tgt_group == src_group: - continue - if _is_allowed(src_group, tgt_group): - continue - violations.append( - ( - modname.removeprefix("pxmodrim."), - imp.removeprefix("pxmodrim."), - tgt_group, - ) - ) - - if violations: - print("\nLayer dependency violations:") - for src_mod, tgt_mod, tgt_group in sorted(violations): - print(f" {src_mod} → {tgt_mod} (forbidden: {tgt_group})") - has_error = True - - if has_error: - print( - "\nFix: move imports to a higher layer, or update\n" - "GROUPS / ALLOWED in scripts/check-deps.py" - ) - return 1 - - print("No circular imports") - print("All layer dependencies OK") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/prune-qt-deploy.sh b/scripts/prune-qt-deploy.sh new file mode 100755 index 0000000..268881d --- /dev/null +++ b/scripts/prune-qt-deploy.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Removes Qt pieces PxModRim never loads from a deployed tree (linuxdeploy, +# windeployqt or macdeployqt output). The UI runs on the Basic Quick Controls +# style, imports no particles or effects and installs no Qt translators. +# +# usage: prune-qt-deploy.sh [keep-dialogs] +# +# QtWebEngine's QML delegates import QtQuick.Dialogs, so the web browser +# variant passes keep-dialogs. +set -euo pipefail + +root="${1:?usage: prune-qt-deploy.sh [keep-dialogs]}" +keep_dialogs="${2:-}" + +remove_dirs() { + find "$root" -type d \( "$@" \) -prune -exec rm -rf {} + +} +remove_files() { + find "$root" \( "$@" \) -prune -exec rm -rf {} + +} + +remove_dirs -path '*/QtQuick/Controls/FluentWinUI3' \ + -o -path '*/QtQuick/Controls/Fusion' \ + -o -path '*/QtQuick/Controls/Imagine' \ + -o -path '*/QtQuick/Controls/Material' \ + -o -path '*/QtQuick/Controls/Universal' \ + -o -path '*/QtQuick/Controls/designer' \ + -o -path '*/QtQuick/Particles' \ + -o -path '*/QtQuick/Effects' + +# Libraries, DLLs and frameworks, named per platform +# (libQt6X.so.6, Qt6X.dll, QtX.framework). +remove_files -name '*QuickControls2FluentWinUI3*' \ + -o -name '*QuickControls2Fusion*' \ + -o -name '*QuickControls2Imagine*' \ + -o -name '*QuickControls2Material*' \ + -o -name '*QuickControls2Universal*' \ + -o -name '*QuickParticles*' \ + -o -name '*QuickEffects*' + +if [ "$keep_dialogs" != keep-dialogs ]; then + remove_dirs -path '*/QtQuick/Dialogs' + remove_files -name '*QuickDialogs2*' +fi + +# Only the .qm catalogs; QtWebEngine keeps its locale .pak files. +find "$root" -path '*/translations/*.qm' -delete diff --git a/scripts/smoke-package.sh b/scripts/smoke-package.sh new file mode 100755 index 0000000..d2dfd57 --- /dev/null +++ b/scripts/smoke-package.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Starts a deployed build headless and fails on missing Qt pieces: QML modules +# or libraries that deployment dropped. +# The bundle ships only the platform plugin of its OS, so Linux needs a display +# (xvfb-run -a scripts/smoke-package.sh ...). +# +# usage: smoke-package.sh [seconds] +set -uo pipefail + +exe="${1:?usage: smoke-package.sh [seconds]}" +seconds="${2:-10}" + +home="$(mktemp -d)" +log="$home/out.txt" +# No `timeout`: it does not exist on macOS runners. +HOME="$home" XDG_CONFIG_HOME="$home/.config" PX_LOG_LEVEL=info \ + "$exe" >"$log" 2>&1 & +pid=$! +sleep "$seconds" +if ! kill "$pid" 2>/dev/null; then + status=0 + wait "$pid" || status=$? + cat "$log" + echo "smoke: app exited early with status $status" >&2 + exit 1 +fi +wait "$pid" 2>/dev/null || true +cat "$log" + +if grep -Eiq 'is not installed|cannot load library|failed to load' "$log"; then + echo "smoke: deployment is missing a Qt component" >&2 + exit 1 +fi +if ! grep -q 'Services initialized' "$log"; then + echo "smoke: startup did not finish" >&2 + exit 1 +fi diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt new file mode 100644 index 0000000..a294257 --- /dev/null +++ b/src/app/CMakeLists.txt @@ -0,0 +1,44 @@ +file(GLOB PX_APP_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +qt_add_executable(pxmodrim ${PX_APP_SOURCES}) +target_link_libraries(pxmodrim PRIVATE pxui pxuiplugin) + +include(GNUInstallDirs) + +# The app reads the Workshop variant from workshop-variant.txt beside its executable +# (px::app::parseDisabledPlugins), so the build tree and the install get the file too. +file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/workshop-variant.txt" + CONTENT "${PX_WORKSHOP_VARIANT}\n") +add_custom_command(TARGET pxmodrim POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_BINARY_DIR}/workshop-variant.txt" "$") + +if(APPLE) + set_target_properties(pxmodrim PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME PxModRim + MACOSX_BUNDLE_GUI_IDENTIFIER com.github.PyXiion.PxModRim + MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION} + MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION} + MACOSX_BUNDLE_ICON_FILE logo.icns) + target_sources(pxmodrim PRIVATE ${CMAKE_SOURCE_DIR}/packaging/logo.icns) + set_source_files_properties(${CMAKE_SOURCE_DIR}/packaging/logo.icns + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + set(PX_APP_INSTALL_DIR pxmodrim.app/Contents/MacOS) + install(TARGETS pxmodrim BUNDLE DESTINATION .) +else() + set(PX_APP_INSTALL_DIR ${CMAKE_INSTALL_BINDIR}) + install(TARGETS pxmodrim RUNTIME DESTINATION ${PX_APP_INSTALL_DIR}) +endif() + +if(WIN32) + file(CONFIGURE OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/pxmodrim.rc" + CONTENT "IDI_ICON1 ICON \"${CMAKE_SOURCE_DIR}/packaging/logo.ico\"\n") + target_sources(pxmodrim PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/pxmodrim.rc") +endif() + +install(FILES "${CMAKE_CURRENT_BINARY_DIR}/workshop-variant.txt" DESTINATION ${PX_APP_INSTALL_DIR}) + +if(UNIX AND NOT APPLE) + install(FILES ${CMAKE_SOURCE_DIR}/packaging/linux/pxmodrim.desktop DESTINATION share/applications) + install(FILES ${CMAKE_SOURCE_DIR}/src/ui/assets/logo.svg RENAME pxmodrim.svg + DESTINATION share/icons/hicolor/scalable/apps) +endif() diff --git a/src/app/main.cpp b/src/app/main.cpp new file mode 100644 index 0000000..c9acc92 --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,248 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#include "app/plugins.hpp" +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/organizer/db.hpp" +#include "core/version.hpp" +#include "ui/components/popup_parent.hpp" +#include "ui/config.hpp" +#include "ui/context.hpp" +#include "ui/theme/palette.hpp" +#include "ui/window/main_window.hpp" + +Q_IMPORT_QML_PLUGIN(PxModRimPlugin) + +namespace { + +constexpr int kWheelScrollLines = 8; +constexpr qint64 kLogMaxBytes = 10 * 1024 * 1024; +constexpr std::size_t kLogRetention = 5; + +// Qt6 defaults to PassThrough, which makes QtWebEngine render at integer buffer +// scale while the compositor upscales fractional scales, which blurs. +// RoundPreferFloor avoids the blur at the cost of slightly smaller UI (the +// QTBUG-113574 workaround). +void setEnvDefaults() { + const std::pair defaults[] = { + {"QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor"}, + {"QT_ENABLE_HIGHDPI_SCALING", "1"}, + {"QT_QUICK_CONTROLS_STYLE", "Basic"}, + }; + for (const auto& [name, value] : defaults) { + if (!qEnvironmentVariableIsSet(name)) { + qputenv(name, value); + } + } +} + +// PX_LOG_LEVEL takes precedence over LOGURU_LEVEL (the legacy name). Unknown +// values and loguru's SUCCESS level fall back to info. +spdlog::level::level_enum logLevelFromEnv() { + for (const char* name : {"PX_LOG_LEVEL", "LOGURU_LEVEL"}) { + const std::string value = + qEnvironmentVariable(name).trimmed().toLower().toStdString(); + if (value.empty()) { + continue; + } + const spdlog::level::level_enum level = spdlog::level::from_str(value); + if (level != spdlog::level::off || value == "off") { + return level; + } + } + return spdlog::level::info; +} + +// Logs to stderr, and to a rotating file under the config directory when it can +// be opened. +void configureLogging(const QString& logsDir) { + std::vector sinks{ + std::make_shared()}; + auto logger = + std::make_shared("pxmodrim", sinks.begin(), sinks.end()); + logger->set_pattern("%Y-%m-%d %H:%M:%S.%e | %^%-8l%$ | %v"); + logger->set_level(logLevelFromEnv()); + spdlog::set_default_logger(logger); + + QDir().mkpath(logsDir); + const std::string logFile = + QDir(logsDir).filePath(QStringLiteral("pxmodrim.log")).toStdString(); + try { + // spdlog reports an unopenable file by throwing; that is the only way it + // reports it. + logger->sinks().push_back( + std::make_shared( + logFile, kLogMaxBytes, kLogRetention)); + } catch (const spdlog::spdlog_ex& ex) { + spdlog::warn("Logging to {} is disabled: {}", logFile, ex.what()); + } +} + +int smokeExitMs() { + bool ok = false; + const int ms = qEnvironmentVariableIntValue("PX_SMOKE_EXIT_MS", &ok); + return ok ? ms : 0; +} + +// Shuts UI plugins down, then the core context. Used once the app has started. +QCoro::Task teardown(px::modrim::AppContext& appContext, + px::modrim::CoreContext& ctx) { + if (auto shut = co_await appContext.shutdownAll(); !shut) { + spdlog::error("Shutdown failed: {}", shut.error().message.toStdString()); + } + co_await ctx.close(); +} + +// Startup, then waiting for the application to quit. Eager: it runs until the +// first suspension, which is the quit wait, so main can tell an early failure +// from a running app (see main()). +QCoro::Task runApp(QApplication& qtApp, QSet disabled) { + spdlog::info("Starting PxModRim"); + + px::modrim::ConfigService configService(px::modrim::configDir()); + px::modrim::AppConfigService appConfigService(configService); + px::modrim::UiPrefsService uiPrefsService(configService); + + if (auto setup = co_await appConfigService.setup(); !setup) { + spdlog::error("Failed to initialize configuration: {}", + setup.error().message.toStdString()); + co_return 1; + } + if (auto setup = co_await uiPrefsService.setup(); !setup) { + spdlog::error("Failed to initialize UI preferences: {}", + setup.error().message.toStdString()); + co_return 1; + } + + px::modrim::AppConfig cfg = appConfigService.cfg(); + spdlog::info("Configuration loaded: game={}, local={}, workshop={}", + cfg.paths.game, cfg.paths.local, cfg.paths.workshop); + + if (cfg.paths.game.empty()) { + spdlog::info("No game path in config, attempting auto-detect"); + if (auto detected = px::modrim::detectGamePaths(); !detected.game.empty()) { + cfg.paths = detected; + if (auto saved = configService.save(QStringLiteral("config.json"), cfg); + !saved) { + spdlog::warn("Cannot save detected game paths: {}", + saved.error().message.toStdString()); + } + } + } + + // Declared before the context, so it outlives the organizer service that + // holds it. + px::modrim::organizer::OrganizerDb organizerDb( + px::modrim::organizer::dbPath(configService.directory())); + auto ctx = px::modrim::CoreContext::create(cfg, &configService); + const QStringList downloaders = px::modrim::registerCorePlugins( + *ctx, configService, organizerDb, disabled); + + px::modrim::AppContext appContext(*ctx, uiPrefsService.prefs()); + px::modrim::PluginSelection selection{std::move(disabled), downloaders}; + px::modrim::registerUiPlugins(appContext, selection); + + if (auto setup = appContext.setupAll(); !setup) { + spdlog::error("Plugin setup failed: {}", + setup.error().message.toStdString()); + ctx->closeSync(); + co_return 1; + } + spdlog::info("Services initialized for game version {}", + ctx->gameVersion().toStdString()); + + // The window opens before mods load, as in the legacy app. + auto window = std::make_unique(appContext); + window->setAppQuitCallback([] { QCoreApplication::quit(); }); + window->show(); + + if (ctx->config().paths.game.empty()) { + spdlog::warn( + "No game path configured; mods are not loaded until one is set"); + } else { + auto refreshed = co_await appContext.refreshMods(); + if (!refreshed) { + spdlog::error("Mod load failed: {}", + refreshed.error().message.toStdString()); + co_await teardown(appContext, *ctx); + co_return 1; + } + spdlog::info("Initial mod load complete: {} mods", *refreshed); + + if (auto init = co_await appContext.initAll(); !init) { + spdlog::error("Plugin init failed: {}", + init.error().message.toStdString()); + co_await teardown(appContext, *ctx); + co_return 1; + } + window->startStartupUpdateCheck(); + } + + // Runs until the application quits (window close, or PX_SMOKE_EXIT_MS). + co_await qCoro(&qtApp, &QCoreApplication::aboutToQuit); + co_await teardown(appContext, *ctx); + co_return 0; +} + +} // namespace + +int main(int argc, char* argv[]) { + setEnvDefaults(); + + QApplication qtApp(argc, argv); + qtApp.setApplicationName(QStringLiteral("PxModRim")); + qtApp.setApplicationVersion(px::modrim::appVersion()); + qtApp.setOrganizationName(QStringLiteral("PxModRim")); + qtApp.setApplicationDisplayName(QStringLiteral("PxModRim")); + qtApp.setDesktopFileName(QStringLiteral("pxmodrim")); + qtApp.setWindowIcon( + QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); + // The app quits when its run finishes, not when the last window closes. + qtApp.setQuitOnLastWindowClosed(false); + qtApp.styleHints()->setWheelScrollLines(kWheelScrollLines); + px::modrim::applyAppTheme(qtApp); + qtApp.installEventFilter(new px::modrim::QuickPopupParenting(&qtApp)); + + configureLogging( + QDir(px::modrim::configDir()).filePath(QStringLiteral("logs"))); + + if (const int smokeMs = smokeExitMs(); smokeMs > 0) { + QTimer::singleShot(smokeMs, &qtApp, &QCoreApplication::quit); + } + + const QString manifest = + QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("workshop-variant.txt")); + auto disabled = px::modrim::parseDisabledPlugins( + qEnvironmentVariable("PX_DISABLED_PLUGINS"), manifest); + if (!disabled) { + spdlog::critical("{}", disabled.error().message.toStdString()); + return 1; + } + + auto run = runApp(qtApp, std::move(*disabled)); + // An early failure has already finished the run; otherwise run the event loop + // until quit. + if (!run.isReady()) { + qtApp.exec(); + } + return QCoro::waitFor(std::move(run)); +} diff --git a/src/app/plugins.cpp b/src/app/plugins.cpp new file mode 100644 index 0000000..b1cb09c --- /dev/null +++ b/src/app/plugins.cpp @@ -0,0 +1,127 @@ +#include "app/plugins.hpp" + +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/organizer/db.hpp" +#include "core/organizer/service.hpp" +#include "core/workshop/catalog.hpp" +#include "ui/plugins/downloads/plugin.hpp" +#include "ui/plugins/organizer/plugin.hpp" +#include "ui/plugins/steam_downloader/plugin.hpp" +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/workshop/plugin.hpp" +#include "ui/views/mods_view.hpp" + +#ifdef PX_HAS_STEAM +#include "core/downloads/steam.hpp" +#endif + +namespace px::modrim { + +std::expected, px::modrim::Error> parseDisabledPlugins( + const QString& envValue, const QString& manifestPath) { + QSet disabled; + for (const QString& part : envValue.split(QLatin1Char(','))) { + const QString name = part.trimmed(); + if (!name.isEmpty()) { + disabled.insert(name); + } + } + + if (!QFileInfo(manifestPath).isFile()) { + return disabled; + } + QFile manifest(manifestPath); + if (!manifest.open(QIODevice::ReadOnly | QIODevice::Text)) { + return std::unexpected( + px::modrim::Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); + } + + const QString variant = QString::fromUtf8(manifest.readAll()).trimmed(); + if (variant == QLatin1StringView("NoWorkshop")) { + disabled.unite(QSet{ + QStringLiteral("steam_downloader"), QStringLiteral("steamworkshop"), + QStringLiteral("downloads_ui"), QStringLiteral("workshop_catalog"), + QStringLiteral("workshop_ui")}); + } else if (variant == QLatin1StringView("NativeWorkshop")) { + disabled.insert(QStringLiteral("steamworkshop")); + } else if (variant == QLatin1StringView("SteamWorkshop")) { + disabled.unite(QSet{QStringLiteral("workshop_catalog"), + QStringLiteral("workshop_ui")}); + } else { + return std::unexpected(px::modrim::Error{ + QStringLiteral("Unknown workshop variant: %1").arg(variant)}); + } + return disabled; +} + +QStringList registerCorePlugins(px::modrim::CoreContext& ctx, + px::modrim::ConfigService& config, + px::modrim::organizer::OrganizerDb& organizerDb, + const QSet& disabled) { + ctx.registerPlugin(std::make_unique( + ctx, organizerDb)); + + QStringList downloaders; + if (!disabled.contains(QStringLiteral("steam_downloader"))) { +#ifdef PX_HAS_STEAM + ctx.registerPlugin(std::make_unique( + px::modrim::SteamDownloaderDeps{ + ctx.downloads(), + config, + [&ctx] { return QString::fromStdString(ctx.config().paths.local); }, + [&ctx] { return ctx.allMods().values(); }, + })); + downloaders.append(QStringLiteral("steam")); +#else + spdlog::info("PxSteamDL is not built in; Steam downloads disabled"); +#endif + } + + // The catalog is only useful with a downloader to fetch items. + if (!downloaders.isEmpty() && + !disabled.contains(QStringLiteral("workshop_catalog"))) { + ctx.registerPlugin( + std::make_unique()); + } + return downloaders; +} + +// How to add a UI plugin (mirrors the legacy _app.py `_setup`): +// - Guard it exactly as _app.py did, e.g. +// if (!selection.disabled.contains(QStringLiteral("workshop_ui")) && +// selection.downloaders.contains("steam")) +// app.registerPlugin(std::make_unique()); +// - Rail views go through app.addRailView(), and must come before the +// plugins +// that read the rail. +// Each port adds one line here; no other file changes. +void registerUiPlugins(px::modrim::AppContext& ctx, + const PluginSelection& selection) { + ctx.addRailView(); + ctx.registerPlugin(std::make_unique()); +#ifdef PX_HAS_STEAM + if (selection.downloaders.contains(QStringLiteral("steam"))) + ctx.registerPlugin(std::make_unique()); + if (selection.downloaders.contains(QStringLiteral("steam")) && + !selection.disabled.contains(QStringLiteral("steamworkshop"))) + ctx.registerPlugin(std::make_unique()); +#endif + if (!selection.downloaders.isEmpty() && + !selection.disabled.contains(QStringLiteral("downloads_ui"))) + ctx.registerPlugin(std::make_unique()); + if (!selection.downloaders.isEmpty() && + !selection.disabled.contains(QStringLiteral("workshop_catalog")) && + !selection.disabled.contains(QStringLiteral("workshop_ui"))) + ctx.registerPlugin( + std::make_unique()); +} + +} // namespace px::modrim diff --git a/src/app/plugins.hpp b/src/app/plugins.hpp new file mode 100644 index 0000000..fdb775c --- /dev/null +++ b/src/app/plugins.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/error.hpp" + +namespace px::modrim { +class ConfigService; +class CoreContext; +namespace organizer { +class OrganizerDb; +} +} // namespace px::modrim + +namespace px::modrim { +class AppContext; +} + +namespace px::modrim { + +// Which plugins this run keeps, and which downloaders came up. Core +// registration produces `downloaders`; UI registration reads both. +struct PluginSelection { + QSet disabled; + // Downloader names, e.g. "steam". Empty when no downloader is available. + QStringList downloaders; +}; + +// Names from PX_DISABLED_PLUGINS (comma-separated) plus the plugins the baked +// workshop variant omits. `manifestPath` is the workshop-variant.txt file; a +// missing file bakes nothing. Fails on an unknown variant name. +std::expected, px::modrim::Error> parseDisabledPlugins( + const QString& envValue, const QString& manifestPath); + +// Registers core plugins in legacy _app.py order and returns the downloaders +// that came up. `organizerDb` is not owned and must outlive `ctx`. +QStringList registerCorePlugins(px::modrim::CoreContext& ctx, + px::modrim::ConfigService& config, + px::modrim::organizer::OrganizerDb& organizerDb, + const QSet& disabled); + +// Registers the UI plugins and rail views. Add one line per plugin here, +// guarded the same way the legacy _app.py guarded it; see plugins.cpp for the +// pattern. +void registerUiPlugins(px::modrim::AppContext& app, + const PluginSelection& selection); + +} // namespace px::modrim diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt new file mode 100644 index 0000000..6cdb67e --- /dev/null +++ b/src/core/CMakeLists.txt @@ -0,0 +1,12 @@ +file(GLOB_RECURSE PX_CORE_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +add_library(pxcore STATIC ${PX_CORE_SOURCES}) +target_include_directories(pxcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_link_libraries(pxcore PUBLIC + Qt6::Core Qt6::Concurrent Qt6::Network Qt6::Sql + QCoro6::Core QCoro6::Network + libzip::zip ZLIB::ZLIB pugixml::pugixml spdlog::spdlog glaze::glaze) + +if(PX_STEAM_LIBS) + target_link_libraries(pxcore PUBLIC ${PX_STEAM_LIBS}) + target_compile_definitions(pxcore PUBLIC PX_HAS_STEAM=1) +endif() diff --git a/src/core/checker/checker.cpp b/src/core/checker/checker.cpp new file mode 100644 index 0000000..cf5694d --- /dev/null +++ b/src/core/checker/checker.cpp @@ -0,0 +1,293 @@ +#include "core/checker/checker.hpp" + +#include +#include + +#include + +namespace px::modrim::checker { +namespace { + +ModDiagnostics compose(const std::vector>& perChecker) { + ModDiagnostics diagnostics; + for (const auto& issues : perChecker) { + for (const ModIssue& issue : issues) { + if (issue.severity == IssueSeverity::kError) { + diagnostics.errors.append(issue); + } else { + diagnostics.warnings.append(issue); + } + } + } + return diagnostics; +} + +// Same key set, and every entry is the same mod object (identity, not +// equality). +bool sameMods(const QHash& a, + const QHash& b) { + if (a.size() != b.size()) { + return false; + } + for (auto it = a.cbegin(); it != a.cend(); ++it) { + const auto other = b.constFind(it.key()); + if (other == b.cend() || other.value() != it.value()) { + return false; + } + } + return true; +} + +} // namespace + +ModChecker::ModChecker(std::vector> checkers, + sort::SortSettings settings, QString targetVersion, + DiagnosticsCallback onDiagnosticsChanged) + : m_checkers(std::move(checkers)), + m_settings(std::move(settings)), + m_targetVersion(std::move(targetVersion)), + m_onDiagnosticsChanged(std::move(onDiagnosticsChanged)) {} + +void ModChecker::setNoVersionWarning(QSet pids) { + m_noVersionWarning = std::move(pids); + m_checkerResults.reset(); +} + +void ModChecker::setUseThisInstead(QHash db) { + m_useThisInstead = std::move(db); + m_checkerResults.reset(); +} + +void ModChecker::setCommunityRules(sort::CommunityRules rules) { + m_communityRules = std::move(rules); +} + +void ModChecker::rebuild(const QHash& mods, + const QStringList& orderedUuids, Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + + { + auto scope = t("collect_active"); + collectActive(mods, orderedUuids); + } + { + auto scope = t("graph.build"); + m_graph.build(m_activeMods, m_orderedPids, m_settings, &m_communityRules); + } + { + auto scope = t("find_cycles"); + m_cachedCycles = m_graph.findCycles(); + } + const CheckContext ctx = buildContext(m_cachedCycles); + { + auto scope = t("check_mod"); + checkAll(ctx); + } + + emitDiagnostics(); +} + +void ModChecker::toggleMod(const ListedMod& mod, + const QStringList& orderedUuids) { + if (dynamic_cast(&mod) == nullptr) { + emitDiagnostics(); + return; + } + rebuild(m_allMods, orderedUuids); +} + +void ModChecker::moveMod(const QString& uuid, int oldIndex, int newIndex) { + if (m_orderedPids.isEmpty()) { + return; + } + const auto uuidIt = m_uuidToPid.constFind(uuid); + if (uuidIt == m_uuidToPid.cend()) { + return; + } + const PackageId pid = uuidIt.value(); + + m_orderedPids.insert(newIndex, m_orderedPids.takeAt(oldIndex)); + m_graph.updateOrder(m_orderedPids); + + const CheckContext ctx = buildContext(m_cachedCycles); + // Only neighbours are rechecked, so cached results no longer match the order. + m_checkerResults.reset(); + + QSet affected{pid}; + affected.unite(m_graph.neighbors(pid)); + for (const PackageId& currentPid : affected) { + if (const auto mod = m_activeMods.constFind(currentPid); + mod != m_activeMods.cend()) { + m_diagnostics.insert(currentPid, checkMod(*mod.value(), ctx)); + } + } + + emitDiagnostics(); +} + +void ModChecker::reorder(const QStringList& orderedUuids) { + const QHash previous = m_activeMods; + const QHash previousIndex = m_graph.pidToIndex(); + collectActive(m_allMods, orderedUuids); + m_graph.updateOrder(m_orderedPids); + + const QList> cycles = m_graph.findCycles(); + const CheckContext ctx = buildContext(cycles); + + if (!m_checkerResults.has_value() || !sameMods(previous, m_activeMods)) { + checkAll(ctx); + } else { + QSet moved; + for (auto it = ctx.pidToIndex.cbegin(); it != ctx.pidToIndex.cend(); ++it) { + if (previousIndex.value(it.key(), -1) != it.value()) { + moved.insert(it.key()); + } + } + + CheckerResults& results = *m_checkerResults; + QSet changed; + for (std::size_t i = 0; i < m_checkers.size(); ++i) { + const ModIssueChecker& checker = *m_checkers[i]; + if (!checker.orderDependent()) { + continue; + } + const auto affected = checker.affectedByMoves(ctx, moved); + for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { + if (affected && !affected->contains(it.key())) { + continue; + } + QList issues = runChecker(checker, *it.value(), ctx); + auto& stored = results[it.key()][i]; + if (issues != stored) { + stored = std::move(issues); + changed.insert(it.key()); + } + } + } + for (const PackageId& pid : changed) { + m_diagnostics.insert(pid, compose(results[pid])); + } + } + + emitDiagnostics(); +} + +const ModDiagnostics* ModChecker::diagnosticsFor(const QString& uuid) const { + const auto pid = m_uuidToPid.constFind(uuid); + if (pid == m_uuidToPid.cend()) { + return nullptr; + } + const auto diagnostics = m_diagnostics.constFind(pid.value()); + return diagnostics == m_diagnostics.cend() ? nullptr : &diagnostics.value(); +} + +DiagnosticsByUuid ModChecker::activeModDiagnostics() const { + DiagnosticsByUuid result; + for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { + if (const auto diagnostics = m_diagnostics.constFind(it.value()); + diagnostics != m_diagnostics.cend()) { + result.insert(it.key(), diagnostics.value()); + } + } + return result; +} + +void ModChecker::collectActive(const QHash& mods, + const QStringList& orderedUuids) { + // `mods` may alias all_mods_ (toggle_mod and reorder pass it back in), so + // copy first. + m_allMods = mods; + m_activeMods.clear(); + m_uuidToPid.clear(); + m_orderedPids.clear(); + + for (const QString& uuid : orderedUuids) { + const auto listed = m_allMods.constFind(uuid); + if (listed == m_allMods.cend()) { + continue; + } + auto about = std::dynamic_pointer_cast(listed.value()); + if (!about) { + continue; + } + const PackageId pid = about->packageId; + m_activeMods.insert(pid, about); + m_uuidToPid.insert(uuid, pid); + m_orderedPids.append(pid); + } +} + +CheckContext ModChecker::buildContext( + const QList>& cycles) const { + return CheckContext{ + .activeMods = m_activeMods, + .orderedPids = m_orderedPids, + .pidToIndex = m_graph.pidToIndex(), + .graph = m_graph, + .settings = m_settings, + .targetVersion = m_targetVersion, + .noVersionWarning = m_noVersionWarning, + .useThisInstead = m_useThisInstead, + .cycles = cycles, + }; +} + +void ModChecker::checkAll(const CheckContext& ctx) { + CheckerResults results; + QHash diagnostics; + for (auto it = m_activeMods.cbegin(); it != m_activeMods.cend(); ++it) { + std::vector> perChecker; + perChecker.reserve(m_checkers.size()); + for (const auto& checker : m_checkers) { + perChecker.push_back(runChecker(*checker, *it.value(), ctx)); + } + diagnostics.insert(it.key(), compose(perChecker)); + results.insert(it.key(), std::move(perChecker)); + } + m_checkerResults = std::move(results); + m_diagnostics = std::move(diagnostics); +} + +ModDiagnostics ModChecker::checkMod(const AboutXmlMod& mod, + const CheckContext& ctx) const { + std::vector> perChecker; + perChecker.reserve(m_checkers.size()); + for (const auto& checker : m_checkers) { + perChecker.push_back(runChecker(*checker, mod, ctx)); + } + return compose(perChecker); +} + +QList ModChecker::runChecker(const ModIssueChecker& checker, + const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (!checker.shouldCheck(mod, ctx)) { + return {}; + } + // One checker must not abort diagnostics for every other mod and checker. + try { + return checker.check(mod, ctx); + } catch (const std::exception& error) { + spdlog::error("Checker failed on {}: {}", mod.packageId.str().toStdString(), + error.what()); + return {}; + } +} + +void ModChecker::emitDiagnostics() const { + if (!m_onDiagnosticsChanged) { + return; + } + DiagnosticsByUuid byUuid; + for (auto it = m_uuidToPid.cbegin(); it != m_uuidToPid.cend(); ++it) { + const auto diagnostics = m_diagnostics.constFind(it.value()); + if (diagnostics != m_diagnostics.cend() && + (diagnostics->hasErrors() || diagnostics->hasWarnings())) { + byUuid.insert(it.key(), diagnostics.value()); + } + } + m_onDiagnosticsChanged(byUuid); +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/checker.hpp b/src/core/checker/checker.hpp new file mode 100644 index 0000000..c81da74 --- /dev/null +++ b/src/core/checker/checker.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/checker/graph.hpp" +#include "core/checker/issues.hpp" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/profiler.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" + +namespace px::modrim::checker { + +// Diagnostics with at least one issue, keyed by mod uuid. +using DiagnosticsByUuid = QHash; +using DiagnosticsCallback = std::function; + +// Runs every checker over the active mods and keeps their diagnostics up to +// date as the mod list, load order and databases change. +class ModChecker { + public: + ModChecker(std::vector> checkers, + sort::SortSettings settings, + QString targetVersion = QStringLiteral("1.5"), + DiagnosticsCallback onDiagnosticsChanged = {}); + + // ── Database config ─────────────────────────────────────── + + void setNoVersionWarning(QSet pids); + void setUseThisInstead(QHash db); + void setCommunityRules(sort::CommunityRules rules); + + // ── Rebuild ─────────────────────────────────────────────── + + // Re-scans all mods and regenerates diagnostics from scratch. + void rebuild(const QHash& mods, + const QStringList& orderedUuids, Profiler* timer = nullptr); + + // ── Incremental updates ─────────────────────────────────── + + // Rebuilds constraints and diagnostics after toggling `mod`. Non-About.xml + // mods only emit. + void toggleMod(const ListedMod& mod, const QStringList& orderedUuids); + + // Moves a mod to a new position and rechecks it and its graph neighbours. + void moveMod(const QString& uuid, int oldIndex, int newIndex); + + // Reapplies the full active-mod order and regenerates the affected + // diagnostics. + void reorder(const QStringList& orderedUuids); + + // ── Query ───────────────────────────────────────────────── + + QHash activeMods() const { return m_activeMods; } + const ConstraintGraph& graph() const noexcept { return m_graph; } + QList orderedPids() const { return m_orderedPids; } + const sort::CommunityRules& communityRules() const noexcept { + return m_communityRules; + } + + // Null when `uuid` is not an active mod. The pointer is valid until the next + // mutation. + const ModDiagnostics* diagnosticsFor(const QString& uuid) const; + DiagnosticsByUuid activeModDiagnostics() const; + + private: + using CheckerResults = QHash>>; + + void collectActive(const QHash& mods, + const QStringList& orderedUuids); + CheckContext buildContext(const QList>& cycles) const; + void checkAll(const CheckContext& ctx); + ModDiagnostics checkMod(const AboutXmlMod& mod, + const CheckContext& ctx) const; + QList runChecker(const ModIssueChecker& checker, + const AboutXmlMod& mod, + const CheckContext& ctx) const; + void emitDiagnostics() const; + + std::vector> m_checkers; + sort::SortSettings m_settings; + QString m_targetVersion; + DiagnosticsCallback m_onDiagnosticsChanged; + + ConstraintGraph m_graph; + QHash m_allMods; + QHash m_diagnostics; + QHash m_activeMods; + QHash m_uuidToPid; + QList m_orderedPids; + + QSet m_noVersionWarning; + QHash m_useThisInstead; + sort::CommunityRules m_communityRules; + QList> m_cachedCycles; + // Per-mod issues from each checker (checker order), reused by reorder() for + // order-independent checkers; nullopt when stale. + std::optional m_checkerResults; +}; + +} // namespace px::modrim::checker diff --git a/src/core/checker/databases.cpp b/src/core/checker/databases.cpp new file mode 100644 index 0000000..e039cd3 --- /dev/null +++ b/src/core/checker/databases.cpp @@ -0,0 +1,438 @@ +#include "core/checker/databases.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/file_io.hpp" +#include "core/xml.hpp" + +namespace px::modrim::checker { +namespace { + +const QString kNoVersionWarningUrl = QStringLiteral( + "https://github.com/emipa606/NoVersionWarning/archive/refs/heads/main.zip"); +const QString kUseThisInsteadUrl = QStringLiteral( + "https://github.com/emipa606/UseThisInstead/archive/refs/heads/main.zip"); + +const QString kNoVersionWarningXml = QStringLiteral("ModIdsToFix.xml"); +const QString kReplacementsGz = QStringLiteral("replacements.json.gz"); + +constexpr int kDownloadTimeoutMs = 30000; + +std::optional fileStamp(const QString& path) { + const QFileInfo info(path); + if (!info.exists()) { + return std::nullopt; + } + return FileStamp{info.lastModified().toMSecsSinceEpoch(), info.size()}; +} + +std::optional fileAgeS(const QString& path) { + const QFileInfo info(path); + if (!info.exists()) { + return std::nullopt; + } + const qint64 ageMs = + info.lastModified().msecsTo(QDateTime::currentDateTime()); + return std::max(0.0, static_cast(ageMs) / 1000.0); +} + +// Downloads `url`, following redirects. Any transport or HTTP error fails the +// download. +QCoro::Task> fetchArchive(QNetworkAccessManager& network, + const QString& url) { + QNetworkRequest request{QUrl(url)}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + request.setTransferTimeout(kDownloadTimeoutMs); + QNetworkReply* reply = network.get(request); + // Released however the coroutine leaves, so a finished reply is never kept + // around. + struct Release { + QNetworkReply* reply; + ~Release() { reply->deleteLater(); } + } release{reply}; + + co_await qCoro(reply).waitForFinished(); + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{reply->errorString()}); + } + co_return reply->readAll(); +} + +Error zipFailure(const char* message) { + return Error{QString::fromUtf8(message)}; +} + +// Reads the first archive entry whose file name is `entry_name`, at any depth. +// Yields nullopt when no entry has that name. +Result> readZipEntry(const QByteArray& archive, + const QString& entryName) { + zip_error_t error; + zip_error_init(&error); + zip_source_t* source = zip_source_buffer_create( + archive.constData(), static_cast(archive.size()), 0, + &error); + if (source == nullptr) { + return std::unexpected( + Error{QString::fromUtf8(zip_error_strerror(&error))}); + } + zip_t* zip = zip_open_from_source(source, ZIP_RDONLY, &error); + if (zip == nullptr) { + zip_source_free(source); + return std::unexpected( + Error{QString::fromUtf8(zip_error_strerror(&error))}); + } + struct Close { + zip_t* zip; + ~Close() { zip_close(zip); } + } close{zip}; + + const zip_int64_t count = zip_get_num_entries(zip, 0); + for (zip_int64_t index = 0; index < count; ++index) { + const auto position = static_cast(index); + const char* name = zip_get_name(zip, position, 0); + if (name == nullptr || + QString::fromUtf8(name).section(QLatin1Char('/'), -1) != entryName) { + continue; + } + + zip_file_t* file = zip_fopen_index(zip, position, 0); + if (file == nullptr) { + return std::unexpected(zipFailure(zip_strerror(zip))); + } + QByteArray bytes; + QByteArray chunk(64 * 1024, Qt::Uninitialized); + zip_int64_t read = 0; + while ((read = zip_fread(file, chunk.data(), + static_cast(chunk.size()))) > 0) { + bytes.append(chunk.constData(), static_cast(read)); + } + const bool failed = read < 0; + zip_fclose(file); + if (failed) { + return std::unexpected(zipFailure(zip_strerror(zip))); + } + return std::optional(std::move(bytes)); + } + return std::optional{}; +} + +// Blocking: writes the archive entry `entry_name` to `target`. Yields false +// when the archive has no such entry, leaving `target` untouched. +Result storeArchiveEntry(const QByteArray& archive, + const QString& entryName, + const QString& target) { + auto entry = readZipEntry(archive, entryName); + if (!entry) { + return std::unexpected(entry.error()); + } + if (!entry->has_value()) { + return false; + } + const QByteArray& bytes = **entry; + auto written = writeFileAtomically( + target, std::string_view(bytes.constData(), + static_cast(bytes.size()))); + if (!written) { + return std::unexpected(written.error()); + } + return true; +} + +// Blocking: inflates a gzip stream in memory. +Result gunzip(const std::string& data) { + z_stream stream{}; + // 16 + MAX_WBITS selects the gzip wrapper, header and CRC included. + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + return std::unexpected(Error{QStringLiteral("Cannot start gzip decoding")}); + } + stream.next_in = reinterpret_cast(const_cast(data.data())); + stream.avail_in = static_cast(data.size()); + + std::string out; + std::array buffer{}; + int status = Z_OK; + do { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + status = inflate(&stream, Z_NO_FLUSH); + if (status != Z_OK && status != Z_STREAM_END) { + // Z_BUF_ERROR here means the input ended before the stream did. + inflateEnd(&stream); + return std::unexpected( + Error{QStringLiteral("Corrupt or truncated gzip data")}); + } + out.append(buffer.data(), buffer.size() - stream.avail_out); + } while (status != Z_STREAM_END); + + inflateEnd(&stream); + return out; +} + +// The ids under ModIdsToFix/li: a lone

  • is a string, several are an array. +// Nullopt when the document has another shape. +std::optional> noVersionWarningIds(const glz::json_t& root) { + QSet ids; + if (!root.contains("ModIdsToFix")) { + return ids; + } + const glz::json_t& section = root["ModIdsToFix"]; + if (!section.is_object()) { + return std::nullopt; + } + if (!section.contains("li")) { + return ids; + } + const glz::json_t& items = section["li"]; + if (items.is_string()) { + ids.insert(PackageId(QString::fromStdString(items.get_string()))); + return ids; + } + if (!items.is_array()) { + return std::nullopt; + } + for (const glz::json_t& item : items.get_array()) { + if (!item.is_string()) { + return std::nullopt; + } + ids.insert(PackageId(QString::fromStdString(item.get_string()))); + } + return ids; +} + +// Blocking: parses the cached NoVersionWarning XML. A broken file yields an +// empty set. +QSet parseNoVersionWarning(const QString& xmlPath) { + const auto data = xmlFileToJson(xmlPath); + if (!data) { + spdlog::error("Failed to load No Version Warning DB: {}", + data.error().message.toStdString()); + return {}; + } + const auto ids = noVersionWarningIds(*data); + if (!ids) { + spdlog::error( + "Failed to load No Version Warning DB: unexpected ModIdsToFix layout"); + return {}; + } + spdlog::info("Loaded {} No Version Warning entries", ids->size()); + return *ids; +} + +// Text of a JSON scalar as Python's str() would show it. Other kinds yield an +// empty string. +QString jsonText(const glz::json_t& object, const char* key) { + if (!object.contains(key)) { + return {}; + } + const glz::json_t& value = object[key]; + if (value.is_string()) { + return QString::fromStdString(value.get_string()); + } + if (value.is_number()) { + const double number = value.get_number(); + if (std::floor(number) == number && std::abs(number) < 1e15) { + return QString::number(static_cast(number)); + } + return QString::number(number, 'g', 17); + } + return {}; +} + +QStringList jsonStringList(const glz::json_t& object, const char* key) { + QStringList values; + if (!object.contains(key) || !object[key].is_array()) { + return values; + } + for (const glz::json_t& item : object[key].get_array()) { + if (item.is_string()) { + values.append(QString::fromStdString(item.get_string())); + } + } + return values; +} + +// Blocking: parses the cached gzipped UseThisInstead JSON. A broken file yields +// no entries. +QHash parseReplacements(const QString& jsonPath) { + const auto bytes = readFileBytes(jsonPath); + if (!bytes) { + spdlog::error("Failed to load Use This Instead DB: {}", + bytes.error().message.toStdString()); + return {}; + } + const auto text = gunzip(*bytes); + if (!text) { + spdlog::error("Failed to load Use This Instead DB: {}", + text.error().message.toStdString()); + return {}; + } + + // Written by a Windows editor in some releases; the UTF-8 BOM is not part of + // the JSON. + std::string body = *text; + if (body.starts_with("\xEF\xBB\xBF")) { + body.erase(0, 3); + } + glz::json_t raw; + if (auto error = glz::read_json(raw, body)) { + spdlog::error("Failed to load Use This Instead DB: {}", + glz::format_error(error, body)); + return {}; + } + + QHash entries; + if (raw.is_object() && raw.contains("rules") && raw["rules"].is_array()) { + for (const glz::json_t& rule : raw["rules"].get_array()) { + if (!rule.is_object() || !rule.contains("oldWorkshopId")) { + continue; + } + ReplacementInfo info; + info.name = jsonText(rule, "newName"); + info.author = jsonText(rule, "newAuthor"); + info.packageid = jsonText(rule, "newPackageId"); + info.pfid = jsonText(rule, "newWorkshopId"); + info.supportedversions = jsonStringList(rule, "newVersions"); + entries.insert(jsonText(rule, "oldWorkshopId"), std::move(info)); + } + } + spdlog::info("Loaded {} Use This Instead entries", entries.size()); + return entries; +} + +} // namespace + +NoVersionWarningService::NoVersionWarningService( + const ConfigService& configService, QNetworkAccessManager& network) + : m_network(network), + m_cacheDir(configService.directory()), + m_xmlPath(QDir(m_cacheDir).filePath(kNoVersionWarningXml)) {} + +QCoro::Task> NoVersionWarningService::ensure(bool force) { + if (!force) { + if (const auto stamp = fileStamp(m_xmlPath)) { + if (stamp != m_loadedStamp) { + m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); + m_loadedStamp = stamp; + } + co_return m_pids; + } + } + co_return co_await download(); +} + +std::optional NoVersionWarningService::cacheAgeS() const { + return fileAgeS(m_xmlPath); +} + +QSet NoVersionWarningService::loadIfExists() { + if (QFileInfo::exists(m_xmlPath)) { + m_pids = parseNoVersionWarning(m_xmlPath); + } + return m_pids; +} + +QCoro::Task> NoVersionWarningService::download() { + QDir().mkpath(m_cacheDir); + + auto archive = co_await fetchArchive(m_network, kNoVersionWarningUrl); + if (!archive) { + spdlog::warn("Failed to download No Version Warning DB: {}", + archive.error().message.toStdString()); + co_return QSet{}; + } + auto stored = co_await QtConcurrent::run( + storeArchiveEntry, std::move(*archive), kNoVersionWarningXml, m_xmlPath); + if (!stored) { + spdlog::warn("Failed to download No Version Warning DB: {}", + stored.error().message.toStdString()); + co_return QSet{}; + } + if (!*stored) { + co_return QSet{}; + } + + m_pids = co_await QtConcurrent::run(parseNoVersionWarning, m_xmlPath); + co_return m_pids; +} + +UseThisInsteadService::UseThisInsteadService(const ConfigService& configService, + QNetworkAccessManager& network) + : m_network(network), + m_cacheDir(configService.directory()), + m_jsonPath(QDir(m_cacheDir).filePath(kReplacementsGz)) {} + +QCoro::Task> UseThisInsteadService::ensure( + bool force) { + if (!force) { + if (const auto stamp = fileStamp(m_jsonPath)) { + if (stamp != m_loadedStamp) { + m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); + m_loadedStamp = stamp; + } + co_return m_entries; + } + } + co_return co_await download(); +} + +std::optional UseThisInsteadService::cacheAgeS() const { + return fileAgeS(m_jsonPath); +} + +QHash UseThisInsteadService::loadIfExists() { + if (QFileInfo::exists(m_jsonPath)) { + m_entries = parseReplacements(m_jsonPath); + } + return m_entries; +} + +QCoro::Task> UseThisInsteadService::download() { + QDir().mkpath(m_cacheDir); + + auto archive = co_await fetchArchive(m_network, kUseThisInsteadUrl); + if (!archive) { + spdlog::warn("Failed to download Use This Instead DB: {}", + archive.error().message.toStdString()); + co_return QHash{}; + } + auto stored = co_await QtConcurrent::run( + storeArchiveEntry, std::move(*archive), kReplacementsGz, m_jsonPath); + if (!stored) { + spdlog::warn("Failed to download Use This Instead DB: {}", + stored.error().message.toStdString()); + co_return QHash{}; + } + if (!*stored) { + co_return QHash{}; + } + + m_entries = co_await QtConcurrent::run(parseReplacements, m_jsonPath); + co_return m_entries; +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/databases.hpp b/src/core/checker/databases.hpp new file mode 100644 index 0000000..4a742e7 --- /dev/null +++ b/src/core/checker/databases.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" + +class QNetworkAccessManager; + +namespace px::modrim { + +class ConfigService; + +namespace checker { + +// Identity of a cache file: changes whenever the file is rewritten. +struct FileStamp { + qint64 modifiedMs = 0; + qint64 size = 0; + + bool operator==(const FileStamp&) const = default; +}; + +// Caches the NoVersionWarning package-id list under the config directory and +// refreshes it from GitHub when the cache is missing or a refresh is forced. +class NoVersionWarningService { + public: + NoVersionWarningService(const ConfigService& configService, + QNetworkAccessManager& network); + + // Cached ids, reloaded when the file changed on disk. Downloads when the + // cache is missing or `force` is set. A failed download yields an empty set + // and keeps the previous ids. + QCoro::Task> ensure(bool force = false); + + // Seconds since the cache file was written, or nullopt when there is no + // cache. + std::optional cacheAgeS() const; + + // Loads the cache synchronously when it exists; otherwise returns the current + // ids. + QSet loadIfExists(); + + private: + QCoro::Task> download(); + + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_xmlPath; + QSet m_pids; + // Stamp of the file the current `pids_` were parsed from. + std::optional m_loadedStamp; +}; + +// Caches the UseThisInstead replacement database (gzipped JSON) under the +// config directory and refreshes it from GitHub when the cache is missing or a +// refresh is forced. +class UseThisInsteadService { + public: + UseThisInsteadService(const ConfigService& configService, + QNetworkAccessManager& network); + + // Cached entries, reloaded when the file changed on disk. Downloads when the + // cache is missing or `force` is set. A failed download yields an empty map + // and keeps the previous entries. + QCoro::Task> ensure(bool force = false); + + std::optional cacheAgeS() const; + + QHash loadIfExists(); + + private: + QCoro::Task> download(); + + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_jsonPath; + QHash m_entries; + std::optional m_loadedStamp; +}; + +} // namespace checker +} // namespace px::modrim diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp new file mode 100644 index 0000000..5c54e5c --- /dev/null +++ b/src/core/checker/graph.cpp @@ -0,0 +1,336 @@ +#include "core/checker/graph.hpp" + +#include +#include +#include + +namespace px::modrim::checker { +namespace { + +constexpr std::size_t indexOfType(EdgeType type) { + return static_cast(type); +} + +bool strLess(const PackageId& a, const PackageId& b) { + return a.str() < b.str(); +} + +// Qt hashes are randomised per process, so the Tarjan traversal visits roots +// and neighbours in name order to keep the reported cycles stable from run to +// run. +QList sortedPids(const QSet& pids) { + QList sorted(pids.cbegin(), pids.cend()); + std::ranges::sort(sorted, strLess); + return sorted; +} + +QHash buildAltMap( + const QHash& activeMods) { + QHash altMap; + for (const auto& mod : activeMods) { + for (const DependencyMod& dep : mod->aboutRules.dependencies) { + if (activeMods.contains(dep.packageId)) { + continue; + } + for (const PackageId& alternativePid : dep.alternativePackageIds) { + if (activeMods.contains(alternativePid)) { + altMap.insert(dep.packageId, alternativePid); + break; + } + } + } + } + return altMap; +} + +} // namespace + +QSet ConstraintGraph::outgoing(const PackageId& pid) const { + QSet result; + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + for (const auto& edges : it.value()) { + result.unite(edges); + } + } + return result; +} + +QSet ConstraintGraph::incoming(const PackageId& pid) const { + QSet result; + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + for (const auto& edges : it.value()) { + result.unite(edges); + } + } + return result; +} + +QSet ConstraintGraph::neighbors(const PackageId& pid) const { + QSet result; + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + for (const auto& edges : it.value()) { + for (const auto& edge : edges) { + result.insert(edge.target); + } + } + } + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + for (const auto& edges : it.value()) { + for (const auto& edge : edges) { + result.insert(edge.source); + } + } + } + return result; +} + +QSet ConstraintGraph::edgesOfType(const PackageId& pid, + EdgeType type) const { + if (const auto it = m_outgoing.constFind(pid); it != m_outgoing.cend()) { + return it.value()[indexOfType(type)]; + } + return {}; +} + +QSet ConstraintGraph::incomingOfType(const PackageId& pid, + EdgeType type) const { + if (const auto it = m_incoming.constFind(pid); it != m_incoming.cend()) { + return it.value()[indexOfType(type)]; + } + return {}; +} + +void ConstraintGraph::build(const QHash& activeMods, + const QList& orderedPids, + const sort::SortSettings& settings, + const sort::CommunityRules* communityRules) { + m_outgoing.clear(); + m_incoming.clear(); + m_cycleComponents.reset(); + m_orderedPids = orderedPids; + rebuildIndex(); + + // Pre-populate nodes so mods without any edges are still tracked. + m_nodes = QSet(orderedPids.cbegin(), orderedPids.cend()); + + const Alternatives alternatives = settings.use_alternative_package_ids + ? buildAltMap(activeMods) + : Alternatives{}; + + for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { + addRules(it.key(), it.value()->aboutRules, EdgeOrigin::kAboutXml, + alternatives, activeMods, settings.use_alternative_package_ids); + } + + if (settings.use_community_rules && communityRules != nullptr && + !communityRules->isEmpty()) { + for (auto it = activeMods.cbegin(); it != activeMods.cend(); ++it) { + const auto rule = communityRules->constFind(it.key()); + if (rule == communityRules->cend()) { + continue; + } + addLoadRules(it.key(), rule->loadBefore, rule->loadAfter, + rule->incompatibleWith, EdgeOrigin::kCommunityRules, + alternatives); + } + } +} + +void ConstraintGraph::removeMod(const PackageId& pid) { + m_cycleComponents.reset(); + const EdgesByType outgoingEdges = m_outgoing.take(pid); + for (const auto& edges : outgoingEdges) { + for (const auto& edge : edges) { + m_incoming[edge.target][indexOfType(edge.type)].remove(edge); + } + } + const EdgesByType incomingEdges = m_incoming.take(pid); + for (const auto& edges : incomingEdges) { + for (const auto& edge : edges) { + m_outgoing[edge.source][indexOfType(edge.type)].remove(edge); + } + } + m_nodes.remove(pid); + m_orderedPids.removeAll(pid); + rebuildIndex(); +} + +void ConstraintGraph::updateOrder(const QList& orderedPids) { + m_orderedPids = orderedPids; + rebuildIndex(); +} + +void ConstraintGraph::rebuildIndex() { + m_pidToIndex.clear(); + for (qsizetype i = 0; i < m_orderedPids.size(); ++i) { + m_pidToIndex.insert(m_orderedPids[i], static_cast(i)); + } +} + +QList> ConstraintGraph::findCycles() { + if (!m_cycleComponents) { + m_cycleComponents = findCycleComponents(); + } + const auto missing = static_cast(m_pidToIndex.size()); + QList> result; + result.reserve(m_cycleComponents->size()); + for (QList component : *m_cycleComponents) { + std::ranges::stable_sort(component, {}, [&](const PackageId& member) { + return m_pidToIndex.value(member, missing); + }); + result.append(std::move(component)); + } + return result; +} + +QList> ConstraintGraph::findCycleComponents() const { + // Nodes without dependency or load-after edges are trivial components that + // can never be part of a cycle, so they are left out of the search. + QHash> adjacency; + for (auto it = m_outgoing.cbegin(); it != m_outgoing.cend(); ++it) { + QSet targets; + for (const EdgeType type : {EdgeType::kDependency, EdgeType::kLoadAfter}) { + for (const auto& edge : it.value()[indexOfType(type)]) { + targets.insert(edge.target); + } + } + if (!targets.isEmpty()) { + adjacency.insert(it.key(), sortedPids(targets)); + } + } + for (auto& neighbors : adjacency) { + neighbors.removeIf( + [&](const PackageId& target) { return !adjacency.contains(target); }); + } + + struct Frame { + PackageId pid; + const QList* neighbors; + qsizetype next; + }; + + QHash indices; + QHash lowlinks; + std::vector activeStack; + QSet onStack; + QList> components; + int nextIndex = 0; + + for (const PackageId& root : + sortedPids(QSet(adjacency.keyBegin(), adjacency.keyEnd()))) { + if (indices.contains(root)) { + continue; + } + + indices.insert(root, nextIndex); + lowlinks.insert(root, nextIndex); + ++nextIndex; + activeStack.push_back(root); + onStack.insert(root); + std::vector dfsStack{Frame{root, &adjacency[root], 0}}; + + while (!dfsStack.empty()) { + Frame& top = dfsStack.back(); + const PackageId pid = top.pid; + const QList& neighbors = *top.neighbors; + + if (top.next == neighbors.size()) { + dfsStack.pop_back(); + if (lowlinks[pid] == indices[pid]) { + QList component; + while (true) { + PackageId member = activeStack.back(); + activeStack.pop_back(); + onStack.remove(member); + const bool isRoot = member == pid; + component.append(std::move(member)); + if (isRoot) { + break; + } + } + if (component.size() > 1 || adjacency[pid].contains(pid)) { + components.append(std::move(component)); + } + } + if (!dfsStack.empty()) { + const PackageId& parent = dfsStack.back().pid; + lowlinks[parent] = std::min(lowlinks[parent], lowlinks[pid]); + } + continue; + } + + const PackageId neighbor = neighbors[top.next++]; + if (!indices.contains(neighbor)) { + indices.insert(neighbor, nextIndex); + lowlinks.insert(neighbor, nextIndex); + ++nextIndex; + activeStack.push_back(neighbor); + onStack.insert(neighbor); + dfsStack.push_back(Frame{neighbor, &adjacency[neighbor], 0}); + } else if (onStack.contains(neighbor) && + indices[neighbor] < lowlinks[pid]) { + lowlinks[pid] = indices[neighbor]; + } + } + } + + return components; +} + +template +void ConstraintGraph::addEdges(const PackageId& source, const Targets& targets, + EdgeType type, EdgeOrigin origin, + const Alternatives& alternatives) { + // Hot path of build(): the per-source edge set is looked up once, not per + // target. + QSet* outEdges = nullptr; + for (const PackageId& rawTarget : targets) { + const PackageId target = alternatives.value(rawTarget, rawTarget); + if (outEdges == nullptr) { + m_nodes.insert(source); + outEdges = &m_outgoing[source][indexOfType(type)]; + } + m_nodes.insert(target); + const ConstraintEdge edge{source, target, type, origin}; + outEdges->insert(edge); + m_incoming[target][indexOfType(type)].insert(edge); + } +} + +void ConstraintGraph::addRules( + const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, + const Alternatives& alternatives, + const QHash& activeMods, + bool useAlternativePackageIds) { + for (const DependencyMod& dep : rules.dependencies) { + const PackageId* target = &dep.packageId; + if (useAlternativePackageIds && !activeMods.contains(dep.packageId)) { + for (const PackageId& alternativePid : dep.alternativePackageIds) { + if (activeMods.contains(alternativePid)) { + target = &alternativePid; + break; + } + } + } + addEdges(pid, std::array{*target}, EdgeType::kDependency, origin, {}); + addEdges(pid, dep.alternativePackageIds, EdgeType::kAlternative, origin, + {}); + } + + addLoadRules(pid, rules.loadBefore, rules.loadAfter, rules.incompatibleWith, + origin, alternatives); +} + +void ConstraintGraph::addLoadRules(const PackageId& pid, + const CaseInsensitiveSet& loadBefore, + const CaseInsensitiveSet& loadAfter, + const CaseInsensitiveSet& incompatibleWith, + EdgeOrigin origin, + const Alternatives& alternatives) { + addEdges(pid, loadAfter, EdgeType::kLoadAfter, origin, alternatives); + addEdges(pid, loadBefore, EdgeType::kLoadBefore, origin, alternatives); + addEdges(pid, incompatibleWith, EdgeType::kIncompatibility, origin, + alternatives); +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/graph.hpp b/src/core/checker/graph.hpp new file mode 100644 index 0000000..c80ef36 --- /dev/null +++ b/src/core/checker/graph.hpp @@ -0,0 +1,113 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" + +namespace px::modrim::checker { + +enum class EdgeType : std::uint8_t { + kDependency, + kLoadAfter, + kLoadBefore, + kIncompatibility, + kAlternative, +}; + +inline constexpr std::size_t kEdgeTypeCount = 5; + +enum class EdgeOrigin : std::uint8_t { + kAboutXml, + kCommunityRules, +}; + +struct ConstraintEdge { + PackageId source; + PackageId target; + EdgeType type; + EdgeOrigin origin; + + bool operator==(const ConstraintEdge&) const = default; + + friend size_t qHash(const ConstraintEdge& edge, size_t seed = 0) noexcept { + return qHashMulti(seed, edge.source, edge.target, edge.type, edge.origin); + } +}; + +// Directed graph of mod dependencies, load-order rules and incompatibilities. +// Nodes are the ordered mods plus any package id a rule points at, whether or +// not it is active. +class ConstraintGraph { + public: + using Alternatives = QHash; + + const QSet& nodes() const noexcept { return m_nodes; } + bool hasNode(const PackageId& pid) const { return m_nodes.contains(pid); } + + // Union over all edge types. Returned by value: Qt containers are implicitly + // shared. + QSet outgoing(const PackageId& pid) const; + QSet incoming(const PackageId& pid) const; + QSet neighbors(const PackageId& pid) const; + QSet edgesOfType(const PackageId& pid, EdgeType type) const; + QSet incomingOfType(const PackageId& pid, + EdgeType type) const; + + int indexOf(const PackageId& pid) const { + return m_pidToIndex.value(pid, -1); + } + const QHash& pidToIndex() const noexcept { + return m_pidToIndex; + } + + // Replaces all edges. A null or empty `community_rules` means no community + // rules. + void build(const QHash& activeMods, + const QList& orderedPids, + const sort::SortSettings& settings, + const sort::CommunityRules* communityRules = nullptr); + + void removeMod(const PackageId& pid); + + // Replaces the mod ordering without touching edges. + void updateOrder(const QList& orderedPids); + + // Strongly connected components that contain a dependency or load-after + // cycle. Members are sorted by load index. Cached until the edges change. + QList> findCycles(); + + private: + using EdgesByType = std::array, kEdgeTypeCount>; + + template + void addEdges(const PackageId& source, const Targets& targets, EdgeType type, + EdgeOrigin origin, const Alternatives& alternatives); + void addRules(const PackageId& pid, const BaseRules& rules, EdgeOrigin origin, + const Alternatives& alternatives, + const QHash& activeMods, + bool useAlternativePackageIds); + void addLoadRules(const PackageId& pid, const CaseInsensitiveSet& loadBefore, + const CaseInsensitiveSet& loadAfter, + const CaseInsensitiveSet& incompatibleWith, + EdgeOrigin origin, const Alternatives& alternatives); + void rebuildIndex(); + QList> findCycleComponents() const; + + QHash m_outgoing; + QHash m_incoming; + QSet m_nodes; + QHash m_pidToIndex; + QList m_orderedPids; + // Cycle membership depends only on edges, so it survives reorders. + std::optional>> m_cycleComponents; +}; + +} // namespace px::modrim::checker diff --git a/src/core/checker/issues.cpp b/src/core/checker/issues.cpp new file mode 100644 index 0000000..cfbbfdd --- /dev/null +++ b/src/core/checker/issues.cpp @@ -0,0 +1,286 @@ +#include "core/checker/issues.hpp" + +#include + +#include "core/checker/graph.hpp" + +namespace px::modrim::checker { + +bool DependencyIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext&) const { + return !mod.aboutRules.dependencies.isEmpty(); +} + +QList DependencyIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (!ctx.settings.check_missing_dependencies) { + return {}; + } + + QList issues; + const bool considerAlternatives = ctx.settings.use_alternative_package_ids; + + for (const DependencyMod& dep : mod.aboutRules.dependencies) { + bool satisfied = ctx.activeMods.contains(dep.packageId); + if (!satisfied && considerAlternatives) { + satisfied = std::ranges::any_of( + dep.alternativePackageIds, + [&](const PackageId& alt) { return ctx.activeMods.contains(alt); }); + } + if (satisfied) { + continue; + } + + QString altNote; + if (considerAlternatives && !dep.alternativePackageIds.empty()) { + QStringList candidates; + for (const PackageId& alt : dep.alternativePackageIds) { + candidates.append(alt.str()); + } + altNote = QStringLiteral(" (alternatives: %1)") + .arg(candidates.join(QStringLiteral(", "))); + } + + issues.append(ModIssue{ + .category = QStringLiteral("missing_dependency"), + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Missing dependencies"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Dependency not satisfied: %1%2") + .arg(dep.packageId.str(), altNote), + .relatedPackageIds = {dep.packageId}, + }); + } + + return issues; +} + +bool IncompatibilityIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + return !mod.aboutRules.incompatibleWith.empty() || + !ctx.graph.edgesOfType(pid, EdgeType::kIncompatibility).isEmpty() || + !ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility).isEmpty(); +} + +QList IncompatibilityIssueChecker::check( + const AboutXmlMod& mod, const CheckContext& ctx) const { + QList issues; + const PackageId& pid = mod.packageId; + + for (const PackageId& incomp : mod.aboutRules.incompatibleWith) { + if (!ctx.activeMods.contains(incomp)) { + continue; + } + const AboutXmlModPtr other = ctx.activeMods.value(incomp); + issues.append(ModIssue{ + .category = QStringLiteral("incompatibility"), + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Mod declares incompatibility with: %1 (%2)") + .arg(other->name, incomp.str()), + .relatedPackageIds = {incomp}, + }); + } + + // Each declaring mod is reported once, even when it is seen both in our own + // list and as an incoming graph edge (about.xml and community rules can both + // declare the same pair). + QSet reportedSources(mod.aboutRules.incompatibleWith.begin(), + mod.aboutRules.incompatibleWith.end()); + for (const ConstraintEdge& edge : + ctx.graph.incomingOfType(pid, EdgeType::kIncompatibility)) { + if (!ctx.activeMods.contains(edge.source) || edge.source == pid || + reportedSources.contains(edge.source)) { + continue; + } + reportedSources.insert(edge.source); + const AboutXmlModPtr other = ctx.activeMods.value(edge.source); + issues.append(ModIssue{ + .category = QStringLiteral("incompatibility"), + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Declared incompatible by: %1 (%2)") + .arg(other->name, edge.source.str()), + .relatedPackageIds = {edge.source}, + }); + } + + return issues; +} + +bool LoadOrderIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + return !ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore).isEmpty() || + !ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter).isEmpty(); +} + +std::optional> LoadOrderIssueChecker::affectedByMoves( + const CheckContext& ctx, const QSet& moved) const { + // Results only read the indices of the mod itself and its edge targets. + QSet affected = moved; + for (const PackageId& pid : moved) { + for (const EdgeType type : {EdgeType::kLoadBefore, EdgeType::kLoadAfter}) { + for (const ConstraintEdge& edge : ctx.graph.incomingOfType(pid, type)) { + affected.insert(edge.source); + } + } + } + return affected; +} + +QList LoadOrderIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + QList issues; + const PackageId& pid = mod.packageId; + const int idx = ctx.pidToIndex.value(pid, -1); + if (idx < 0) { + return issues; + } + + for (const ConstraintEdge& edge : + ctx.graph.edgesOfType(pid, EdgeType::kLoadBefore)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx >= 0 && idx >= otherIdx) { + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .category = QStringLiteral("load_order"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Should load before: %1 (%2), but appears after " + "(index %3 vs %4)") + .arg(name, edge.target.str()) + .arg(idx) + .arg(otherIdx), + .relatedPackageIds = {edge.target}, + }); + } + } + + for (const ConstraintEdge& edge : + ctx.graph.edgesOfType(pid, EdgeType::kLoadAfter)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx >= 0 && idx <= otherIdx) { + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .category = QStringLiteral("load_order"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Should load after: %1 (%2), but appears before " + "(index %3 vs %4)") + .arg(name, edge.target.str()) + .arg(idx) + .arg(otherIdx), + .relatedPackageIds = {edge.target}, + }); + } + } + + return issues; +} + +QList CycleIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const PackageId& pid = mod.packageId; + QList issues; + + for (const QList& cycle : ctx.cycles) { + if (!cycle.contains(pid)) { + continue; + } + QStringList members; + QList related; + for (const PackageId& member : cycle) { + members.append(member.str()); + if (member != pid) { + related.append(member); + } + } + issues.append(ModIssue{ + .category = QStringLiteral("cycle"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Dependency cycle detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Participates in a dependency cycle involving: ") + + members.join(QStringLiteral(", ")), + .relatedPackageIds = std::move(related), + }); + } + + return issues; +} + +bool GameVersionIssueChecker::shouldCheck(const AboutXmlMod& mod, + const CheckContext&) const { + return !mod.supportedVersions.isEmpty(); +} + +QList GameVersionIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + if (mod.supportedVersions.isEmpty()) { + return {}; + } + if (ctx.noVersionWarning.contains(mod.packageId)) { + return {}; + } + + // Only major.minor is compared, so a patch-level target still matches "1.5" + // support. + const QStringList targetParts = + ctx.targetVersion.split(QLatin1Char('.')).mid(0, 2); + const QString targetMajorMinor = targetParts.join(QLatin1Char('.')); + if (mod.supportedVersions.contains(targetMajorMinor)) { + return {}; + } + + QStringList supported(mod.supportedVersions.cbegin(), + mod.supportedVersions.cend()); + std::ranges::sort(supported); + return {ModIssue{ + .category = QStringLiteral("version_mismatch"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Version mismatch"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Mod supports %1, but game is on %2") + .arg(supported.join(QStringLiteral(", ")), targetMajorMinor), + .relatedPackageIds = {}, + }}; +} + +QList ReplacementIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const std::optional pfid = mod.publishedFileId(); + if (!pfid || pfid->isEmpty()) { + return {}; + } + + const auto replacement = ctx.useThisInstead.constFind(*pfid); + if (replacement == ctx.useThisInstead.cend()) { + return {}; + } + const ReplacementInfo& info = replacement.value(); + + return {ModIssue{ + .category = QStringLiteral("replacement_available"), + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Replacement available"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Recommended replacement: %1 (%2) by %3") + .arg(info.name, info.packageid, info.author), + .relatedPackageIds = {PackageId(info.packageid)}, + }}; +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/issues.hpp b/src/core/checker/issues.hpp new file mode 100644 index 0000000..d8ab99b --- /dev/null +++ b/src/core/checker/issues.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/checker/models.hpp" + +namespace px::modrim::checker { + +class ModIssueChecker { + public: + virtual ~ModIssueChecker() = default; + + const QString& categoryDisplayName() const noexcept { + return m_categoryDisplayName; + } + + // Order-independent results are reused when only the load order changes. + bool orderDependent() const noexcept { return m_orderDependent; } + + virtual bool shouldCheck(const AboutXmlMod&, const CheckContext&) const { + return true; + } + + // Mods whose results may change when `moved` changed index; nullopt means all + // mods. + virtual std::optional> affectedByMoves( + const CheckContext&, const QSet&) const { + return std::nullopt; + } + + virtual QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const = 0; + + protected: + ModIssueChecker(QString categoryDisplayName, bool orderDependent) + : m_categoryDisplayName(std::move(categoryDisplayName)), + m_orderDependent(orderDependent) {} + + private: + QString m_categoryDisplayName; + bool m_orderDependent; +}; + +// Missing or unsatisfied mod dependencies, honouring alternative package ids +// when enabled. +class DependencyIssueChecker final : public ModIssueChecker { + public: + DependencyIssueChecker() + : ModIssueChecker(QStringLiteral("Missing Dependency"), false) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; +}; + +// Incompatibilities declared by the mod itself, and declared by other active +// mods against it. +class IncompatibilityIssueChecker final : public ModIssueChecker { + public: + IncompatibilityIssueChecker() + : ModIssueChecker(QStringLiteral("Incompatibility"), false) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; +}; + +// Load-before and load-after constraints that the current order violates. +class LoadOrderIssueChecker final : public ModIssueChecker { + public: + LoadOrderIssueChecker() + : ModIssueChecker(QStringLiteral("Load Order"), true) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + std::optional> affectedByMoves( + const CheckContext& ctx, const QSet& moved) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; +}; + +// Membership in a dependency cycle reported by the graph. +class CycleIssueChecker final : public ModIssueChecker { + public: + CycleIssueChecker() : ModIssueChecker(QStringLiteral("Cycle"), true) {} + + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; +}; + +// Supported game versions that lack the target version. +class GameVersionIssueChecker final : public ModIssueChecker { + public: + GameVersionIssueChecker() + : ModIssueChecker(QStringLiteral("Version Mismatch"), false) {} + + bool shouldCheck(const AboutXmlMod& mod, + const CheckContext& ctx) const override; + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; +}; + +// A known replacement for the mod's Workshop file id. +class ReplacementIssueChecker final : public ModIssueChecker { + public: + ReplacementIssueChecker() + : ModIssueChecker(QStringLiteral("Replacement Available"), false) {} + + QList check(const AboutXmlMod& mod, + const CheckContext& ctx) const override; +}; + +} // namespace px::modrim::checker diff --git a/src/core/checker/models.cpp b/src/core/checker/models.cpp new file mode 100644 index 0000000..51ae744 --- /dev/null +++ b/src/core/checker/models.cpp @@ -0,0 +1,25 @@ +#include "core/checker/models.hpp" + +namespace px::modrim::checker { + +QString ModDiagnostics::errorTooltip() const { + if (errors.isEmpty()) { + return {}; + } + if (errors.size() == 1) { + return errors.front().shortMessage; + } + return QStringLiteral("%1 errors").arg(errors.size()); +} + +QString ModDiagnostics::warningTooltip() const { + if (warnings.isEmpty()) { + return {}; + } + if (warnings.size() == 1) { + return warnings.front().shortMessage; + } + return QStringLiteral("%1 warnings").arg(warnings.size()); +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/models.hpp b/src/core/checker/models.hpp new file mode 100644 index 0000000..df48762 --- /dev/null +++ b/src/core/checker/models.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" + +namespace px::modrim::checker { + +using PackageId = CaseInsensitiveStr; +using AboutXmlModPtr = std::shared_ptr; + +class ConstraintGraph; + +enum class IssueSeverity : std::uint8_t { kError, kWarning }; + +struct ModIssue { + QString category; + IssueSeverity severity; + QString shortMessage; + QString categoryDisplayName; + QString detailMessage; + QList relatedPackageIds; + + bool operator==(const ModIssue&) const = default; +}; + +struct ModDiagnostics { + QList errors; + QList warnings; + + bool hasErrors() const noexcept { return !errors.isEmpty(); } + bool hasWarnings() const noexcept { return !warnings.isEmpty(); } + // The single issue's message, or a count once there is more than one. + QString errorTooltip() const; + QString warningTooltip() const; + + bool operator==(const ModDiagnostics&) const = default; +}; + +// Everything a checker may read while checking one mod. Callers own the +// referenced data and keep it alive for the duration of the check. +struct CheckContext { + const QHash& activeMods; + const QList& orderedPids; + const QHash& pidToIndex; + const ConstraintGraph& graph; + const sort::SortSettings& settings; + QString targetVersion = QStringLiteral("1.5"); + const QSet& noVersionWarning; + const QHash& useThisInstead; + const QList>& cycles; +}; + +} // namespace px::modrim::checker diff --git a/src/core/checker/sort.cpp b/src/core/checker/sort.cpp new file mode 100644 index 0000000..b27e94d --- /dev/null +++ b/src/core/checker/sort.cpp @@ -0,0 +1,240 @@ +#include "core/checker/sort.hpp" + +#include +#include +#include +#include +#include + +#include "core/sort/tiers.hpp" + +namespace px::modrim::checker { +namespace { + +using sort::PackageGraph; +using sort::Tier; + +// Dependency edges point from a mod to the mods that must load before it; +// load-before edges are reversed so both shapes end up in the same map. +std::pair buildDepsAndRevDeps( + const ConstraintGraph& graph, const QSet& allPids) { + PackageGraph deps; + PackageGraph revDeps; + for (const PackageId& pid : allPids) { + deps.insert(pid, {}); + revDeps.insert(pid, {}); + } + + for (const PackageId& pid : graph.nodes()) { + if (!allPids.contains(pid)) { + continue; + } + for (const ConstraintEdge& edge : graph.outgoing(pid)) { + if (!allPids.contains(edge.target)) { + continue; + } + switch (edge.type) { + case EdgeType::kLoadBefore: + deps[edge.target].insert(pid); + revDeps[pid].insert(edge.target); + break; + case EdgeType::kLoadAfter: + case EdgeType::kDependency: + deps[pid].insert(edge.target); + revDeps[edge.target].insert(pid); + break; + case EdgeType::kIncompatibility: + case EdgeType::kAlternative: + break; + } + } + } + + return {std::move(deps), std::move(revDeps)}; +} + +// Mods that community rules flag through `flag`, or none when community rules +// are disabled. +QSet communityFlagged(const sort::CommunityRules* rules, + const QSet& allPids, + const sort::SortSettings& settings, + bool sort::CommunityRule::* flag) { + QSet flagged; + if (!settings.use_community_rules || rules == nullptr || rules->isEmpty()) { + return flagged; + } + for (const PackageId& pid : allPids) { + const auto rule = rules->constFind(pid); + if (rule != rules->cend() && rule.value().*flag) { + flagged.insert(pid); + } + } + return flagged; +} + +// Position of each package id in the tier config, so the user's explicit picks +// sort first. +QHash buildConfigPriority(const sort::TierConfig& tierConfig) { + QHash priority; + for (std::size_t i = 0; i < tierConfig.tier_0.size(); ++i) { + priority.insert(tierConfig.tier_0[i], static_cast(i)); + } + // Tier 1 and tier 3 only fill in ids that tier 0 did not already place, and + // they are numbered after the earlier lists. + int base = static_cast(tierConfig.tier_0.size()); + for (const auto* list : {&tierConfig.tier_1, &tierConfig.tier_3}) { + for (std::size_t i = 0; i < list->size(); ++i) { + const PackageId& pid = (*list)[i]; + if (!priority.contains(pid)) { + priority.insert(pid, base + static_cast(i)); + } + } + base += static_cast(list->size()); + } + return priority; +} + +struct SortKey { + int priority; + PackageId pid; +}; + +SortKey makeKey(const PackageId& pid, + const QHash& configPriority) { + return SortKey{ + configPriority.value(pid, static_cast(configPriority.size())), pid}; +} + +bool keyLess(const SortKey& a, const SortKey& b) { + if (a.priority != b.priority) { + return a.priority < b.priority; + } + return a.pid.str() < b.pid.str(); +} + +// Heap order that pops the smallest key first. +bool keyGreater(const SortKey& a, const SortKey& b) { return keyLess(b, a); } + +} // namespace + +QList topologicalSort( + const QHash>& activeMods, + const ConstraintGraph& graph, const sort::SortSettings& settings, + const sort::CommunityRules* communityRules, Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + + const QSet allPids(activeMods.keyBegin(), activeMods.keyEnd()); + if (allPids.isEmpty()) { + return {}; + } + + PackageGraph deps; + PackageGraph revDeps; + { + auto scope = t("build_deps"); + std::tie(deps, revDeps) = buildDepsAndRevDeps(graph, allPids); + } + QSet loadFirst; + { + auto scope = t("build_load_first"); + loadFirst = communityFlagged(communityRules, allPids, settings, + &sort::CommunityRule::loadFirst); + } + QSet loadLast; + { + auto scope = t("build_load_last"); + loadLast = communityFlagged(communityRules, allPids, settings, + &sort::CommunityRule::loadLast); + } + + QHash tiers; + { + auto scope = t("assign_tiers"); + tiers = sort::assignTiers(allPids, deps, revDeps, loadFirst, loadLast, + settings.tier_config); + } + + constexpr std::array, 4> kTierPasses{{ + {Tier::kTier0, "kahn_tier_0"}, + {Tier::kTier1, "kahn_tier_1"}, + {Tier::kTier2, "kahn_tier_2"}, + {Tier::kTier3, "kahn_tier_3"}, + }}; + const QHash configPriority = + buildConfigPriority(settings.tier_config); + + QList order; + QSet ordered; + for (const auto& [tier, label] : kTierPasses) { + QSet tierPids; + for (const PackageId& pid : allPids) { + if (ordered.contains(pid)) { + continue; + } + const auto assigned = tiers.constFind(pid); + if (assigned != tiers.cend() && assigned.value() == tier) { + tierPids.insert(pid); + } + } + if (tierPids.isEmpty()) { + continue; + } + + auto scope = t(label); + + // Dependencies are restricted to this tier: earlier tiers are already + // placed, and later tiers must not hold up this one. + QHash indegree; + QHash> rev; + for (const PackageId& pid : tierPids) { + QSet subDeps = deps.value(pid); + subDeps.intersect(tierPids); + indegree.insert(pid, static_cast(subDeps.size())); + for (const PackageId& dep : subDeps) { + rev[dep].insert(pid); + } + } + + std::vector queue; + for (const PackageId& pid : tierPids) { + if (indegree.value(pid) == 0) { + queue.push_back(makeKey(pid, configPriority)); + } + } + std::ranges::make_heap(queue, keyGreater); + + while (!queue.empty()) { + std::ranges::pop_heap(queue, keyGreater); + const PackageId pid = queue.back().pid; + queue.pop_back(); + order.append(pid); + ordered.insert(pid); + + for (const PackageId& dependent : rev.value(pid)) { + if (--indegree[dependent] == 0) { + queue.push_back(makeKey(dependent, configPriority)); + std::ranges::push_heap(queue, keyGreater); + } + } + } + + // Whatever is left sits on a dependency cycle; keep it deterministic at the + // tier's end. + std::vector remaining; + for (const PackageId& pid : tierPids) { + if (!ordered.contains(pid)) { + remaining.push_back(makeKey(pid, configPriority)); + } + } + std::ranges::sort(remaining, keyLess); + for (const SortKey& key : remaining) { + order.append(key.pid); + ordered.insert(key.pid); + } + } + + return order; +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/sort.hpp b/src/core/checker/sort.hpp new file mode 100644 index 0000000..b80ac8b --- /dev/null +++ b/src/core/checker/sort.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "core/checker/graph.hpp" +#include "core/checker/models.hpp" +#include "core/profiler.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" + +namespace px::modrim::checker { + +// Orders active mods by tier, then by dependencies and load-order constraints, +// breaking ties by the tier config priority and then by package id. Mods caught +// in a cycle are appended at the end of their tier in the same key order. +QList topologicalSort( + const QHash>& activeMods, + const ConstraintGraph& graph, const sort::SortSettings& settings, + const sort::CommunityRules* communityRules = nullptr, + Profiler* timer = nullptr); + +} // namespace px::modrim::checker diff --git a/src/core/config.cpp b/src/core/config.cpp new file mode 100644 index 0000000..c2224bc --- /dev/null +++ b/src/core/config.cpp @@ -0,0 +1,512 @@ +#include "core/config.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/constants.hpp" + +namespace px::modrim { + +namespace { + +const QString kConfigFile = QStringLiteral("config.json"); +const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); + +// Schema versions above this are far beyond any real file; clamping keeps them +// representable while still being rejected as unsupported. +constexpr double kMaxSchemaVersion = 1e15; + +QString steamAppId() { return QString(kRimworldSteamAppId); } + +QString joinPath(QString path, std::initializer_list parts) { + for (const auto& part : parts) { + path.append(u'/').append(part); + } + return QDir::cleanPath(path); +} + +bool isDir(const QString& path) { return QFileInfo(path).isDir(); } + +QString findRimworldInSteamRoot(const QString& root, const QString& steamId); + +// Parses a libraryfolders.vdf for an alternate library holding RimWorld. +QString vdfFindRimworld(const QString& vdfPath, const QString& steamId) { + QFile file(vdfPath); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::warn("Failed to parse libraryfolders.vdf"); + return {}; + } + const QString text = QString::fromUtf8(file.readAll()); + + // Matches `"path" ""` and `"" {` lines of libraryfolders.vdf. + static const QRegularExpression pathPattern( + QStringLiteral(R"re("path"\s+"(.+?)")re")); + const QRegularExpression appPattern(QStringLiteral("\"") + + QRegularExpression::escape(steamId) + + QStringLiteral(R"re("\s*\{)re")); + + QString currentPath; + for (const auto& rawLine : text.split(u'\n')) { + const QString line = rawLine.trimmed(); + if (const auto match = pathPattern.match(line); match.hasMatch()) { + currentPath = match.captured(1); + continue; + } + if (!currentPath.isEmpty() && appPattern.match(line).hasMatch()) { + const QString game = joinPath( + currentPath, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(game)) { + return game; + } + } + } + return {}; +} + +QString findRimworldInSteamRoot(const QString& root, const QString& steamId) { + const QString game = + joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(game)) { + return game; + } + const QString configVdf = joinPath( + root, {QStringLiteral("config"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(configVdf)) { + return vdfFindRimworld(configVdf, steamId); + } + const QString steamappsVdf = joinPath( + root, + {QStringLiteral("steamapps"), QStringLiteral("libraryfolders.vdf")}); + if (QFileInfo::exists(steamappsVdf)) { + return vdfFindRimworld(steamappsVdf, steamId); + } + return {}; +} + +// Workshop content lives beside the `common` folder that holds the game. +QString workshopPathFromGame(const QString& game, const QString& steamRoot, + const QString& steamId) { + const QString normalized = QDir::fromNativeSeparators(game); + const QStringList parts = normalized.split(u'/', Qt::SkipEmptyParts); + const qsizetype common = parts.indexOf(QStringLiteral("common")); + if (common >= 0) { + QString base = parts.mid(0, common).join(u'/'); + if (normalized.startsWith(u'/')) { + base.prepend(u'/'); + } + const QString workshop = joinPath( + base, {QStringLiteral("workshop"), QStringLiteral("content"), steamId}); + if (isDir(workshop)) { + return workshop; + } + } + return joinPath(steamRoot, + {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId}); +} + +struct GamePaths { + QString game; + QString local; + QString workshop; +}; + +GamePaths gamePathsFor(const QString& game, const QString& workshop) { + return GamePaths{ + .game = game, + .local = joinPath(game, {QStringLiteral("Mods")}), + .workshop = workshop, + }; +} + +#if defined(Q_OS_LINUX) +GamePaths findGamePaths(QString& foundRoot) { + const QString steamId = steamAppId(); + const QString home = QDir::homePath(); + const QStringList candidates = { + joinPath(home, {QStringLiteral(".steam"), + QStringLiteral("debian-installation")}), + joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}), + joinPath(home, {QStringLiteral(".local"), QStringLiteral("share"), + QStringLiteral("Steam")}), + joinPath(home, {QStringLiteral(".var"), QStringLiteral("app"), + QStringLiteral("com.valvesoftware.Steam"), + QStringLiteral(".local"), QStringLiteral("share"), + QStringLiteral("Steam")}), + joinPath(home, {QStringLiteral("snap"), QStringLiteral("steam"), + QStringLiteral("common"), QStringLiteral(".local"), + QStringLiteral("share"), QStringLiteral("Steam")}), + }; + for (const auto& root : candidates) { + if (!isDir(root)) { + continue; + } + if (const QString game = findRimworldInSteamRoot(root, steamId); + !game.isEmpty()) { + foundRoot = root; + return gamePathsFor(game, workshopPathFromGame(game, root, steamId)); + } + } + + const QString fallbackRoot = + joinPath(home, {QStringLiteral(".steam"), QStringLiteral("steam")}); + const QString fallback = joinPath( + fallbackRoot, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(fallback)) { + foundRoot = isDir(fallbackRoot) ? fallbackRoot : QString(); + return gamePathsFor( + fallback, joinPath(fallbackRoot, {QStringLiteral("steamapps"), + QStringLiteral("workshop"), + QStringLiteral("content"), steamId})); + } + return {}; +} +#elif defined(Q_OS_MACOS) +QString find_mac_app(const QString& rimworld_dir) { + if (!isDir(rimworld_dir)) { + return {}; + } + const QStringList apps = + QDir(rimworld_dir) + .entryList({QStringLiteral("*.app")}, + QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot, + QDir::Name); + return apps.isEmpty() ? QString() : joinPath(rimworld_dir, {apps.first()}); +} + +GamePaths findGamePaths(QString& foundRoot) { + const QString steamId = steamAppId(); + const QString steamRoot = + joinPath(QDir::homePath(), {QStringLiteral("Library"), + QStringLiteral("Application Support"), + QStringLiteral("Steam")}); + foundRoot = isDir(steamRoot) ? steamRoot : QString(); + if (const QString game = findRimworldInSteamRoot(steamRoot, steamId); + !game.isEmpty()) { + return gamePathsFor(game, workshopPathFromGame(game, steamRoot, steamId)); + } + const QString fallback = joinPath( + steamRoot, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (const QString app_bundle = find_mac_app(fallback); + !app_bundle.isEmpty()) { + return GamePaths{ + .game = app_bundle, + .local = joinPath(fallback, {QStringLiteral("Mods")}), + .workshop = joinPath( + steamRoot, {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId}), + }; + } + return {}; +} +#elif defined(Q_OS_WIN) +// Steam's install location is recorded in the registry; the 32-bit view is +// checked first, as the legacy detection did. +GamePaths findGamePaths(QString& foundRoot) { + const QString steamId = steamAppId(); + const QStringList registry_keys = { + QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Valve\Steam)"), + QStringLiteral(R"(HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam)"), + }; + for (const auto& key : registry_keys) { + const QString steamRoot = QSettings(key, QSettings::NativeFormat) + .value(QStringLiteral("InstallPath")) + .toString(); + if (steamRoot.isEmpty()) { + spdlog::debug("Unable to inspect Windows Steam registry key {}", + key.toStdString()); + continue; + } + if (const QString game = findRimworldInSteamRoot(steamRoot, steamId); + !game.isEmpty()) { + return gamePathsFor(game, workshopPathFromGame(game, steamRoot, steamId)); + } + } + + const QString fallback = QStringLiteral("C:/Program Files (x86)/Steam"); + const QString game = + joinPath(fallback, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); + if (isDir(game)) { + return gamePathsFor( + game, joinPath(fallback, + {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId})); + } + return {}; +} +#else +GamePaths findGamePaths(QString&) { return {}; } +#endif + +// Locates the RimWorld Config folder, which the game writes beside its save +// data. +QString detectConfigFolder([[maybe_unused]] const QString& steamRoot) { +#if defined(Q_OS_LINUX) + const QString steamId = steamAppId(); + if (!steamRoot.isEmpty()) { + const QString proton = joinPath( + steamRoot, {QStringLiteral("steamapps"), QStringLiteral("compatdata"), + steamId, QStringLiteral("pfx"), QStringLiteral("drive_c"), + QStringLiteral("users"), QStringLiteral("steamuser"), + QStringLiteral("AppData"), QStringLiteral("LocalLow"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), + QStringLiteral("Config")}); + if (isDir(proton)) { + spdlog::debug("Detected Proton config folder: {}", proton.toStdString()); + return proton; + } + } + const QString native = joinPath( + QDir::homePath(), + {QStringLiteral(".config"), QStringLiteral("unity3d"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); + if (isDir(native)) { + spdlog::debug("Detected native config folder: {}", native.toStdString()); + return native; + } +#elif defined(Q_OS_MACOS) + const QString config = joinPath( + QDir::homePath(), + {QStringLiteral("Library"), QStringLiteral("Application Support"), + QStringLiteral("Rimworld"), QStringLiteral("Config")}); + if (isDir(config)) { + spdlog::debug("Detected macOS config folder: {}", config.toStdString()); + return config; + } +#elif defined(Q_OS_WIN) + const QString config = joinPath( + QDir::homePath(), + {QStringLiteral("AppData"), QStringLiteral("LocalLow"), + QStringLiteral("Ludeon Studios"), + QStringLiteral("RimWorld by Ludeon Studios"), QStringLiteral("Config")}); + if (isDir(config)) { + spdlog::debug("Detected Windows config folder: {}", config.toStdString()); + return config; + } +#endif + return {}; +} + +} // namespace + +ConfigService::ConfigService(QString configDir, JsonMigrations migrations) + : m_dir(std::move(configDir)), m_migrations(std::move(migrations)) {} + +const QString& ConfigService::directory() const noexcept { return m_dir; } + +QString ConfigService::filePath(const QString& filename) const { + return QDir(m_dir).filePath(filename); +} + +bool ConfigService::isVersioned(const QString& filename) { + return filename == kConfigFile || filename == kUiPrefsFile; +} + +std::string ConfigService::withSchemaMarker(std::string prettyObject) { + // Pretty output ends with the object's closing brace; the marker goes after + // the last non-space character before it, which is the last member's value or + // the opening brace. + const auto close = prettyObject.rfind('}'); + if (close == std::string::npos) { + return prettyObject; + } + auto last = prettyObject.find_last_not_of(" \t\r\n", close - 1); + if (last == std::string::npos) { + return prettyObject; + } + const bool emptyObject = prettyObject[last] == '{'; + const std::string marker = + std::string("\"") + std::string(kSchemaVersionKey) + + "\": " + std::to_string(kCurrentConfigSchemaVersion); + prettyObject.insert(last + 1, + emptyObject ? "\n " + marker + "\n" : ",\n " + marker); + return prettyObject; +} + +JsonObject ConfigService::loadRaw(const QString& filename) const { + auto bytes = readFileBytes(filePath(filename)); + if (!bytes) { + return {}; + } + glz::json_t document; + if (glz::read_json(document, *bytes) || !document.is_object()) { + return {}; + } + return std::move(document.get_object()); +} + +Result> ConfigService::loadVersioned( + const QString& filename) const { + const QString path = filePath(filename); + auto bytes = readFileBytes(path); + if (!bytes) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + bytes.error().message.toStdString()); + return std::optional{}; + } + + glz::json_t document; + if (auto error = glz::read_json(document, *bytes)) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + glz::format_error(error, *bytes)); + return std::optional{}; + } + if (!document.is_object()) { + spdlog::warn("Failed to load {}: top-level value is not an object", + filename.toStdString()); + return std::optional{}; + } + JsonObject raw = std::move(document.get_object()); + + // A missing marker or an explicit null means schema version 0. + std::int64_t version = 0; + if (auto marker = raw.find(std::string(kSchemaVersionKey)); + marker != raw.end()) { + const auto& value = marker->second; + if (!value.is_null()) { + const bool valid = value.is_number() && value.get() >= 0 && + std::trunc(value.get()) == value.get(); + if (!valid) { + spdlog::warn("Failed to load {}: Invalid JSON schema version", + filename.toStdString()); + return std::optional{}; + } + version = static_cast( + std::min(value.get(), kMaxSchemaVersion)); + } + raw.erase(marker); + } + + if (auto migrated = migrate(raw, version); !migrated) { + return std::unexpected(migrated.error()); + } + if (version == kCurrentConfigSchemaVersion) { + return std::optional(std::move(raw)); + } + + // Keep the pre-migration file as a backup before the rewritten one replaces + // it. + const QString backup = path + QStringLiteral(".bak.") + + QString::number(QDateTime::currentSecsSinceEpoch()); + if (auto copied = copyFileReplacing(path, backup); !copied) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + copied.error().message.toStdString()); + return std::optional{}; + } + raw[std::string(kSchemaVersionKey)] = + static_cast(kCurrentConfigSchemaVersion); + auto text = glz::write(raw); + if (!text) { + spdlog::warn("Failed to load {}: cannot encode migrated document", + filename.toStdString()); + return std::optional{}; + } + if (auto written = writeFileAtomically(path, *text); !written) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + written.error().message.toStdString()); + return std::optional{}; + } + return std::optional(std::move(raw)); +} + +Result ConfigService::migrate(JsonObject& data, + std::int64_t current) const { + if (current > kCurrentConfigSchemaVersion) { + return std::unexpected( + Error{QStringLiteral("Unsupported JSON schema version %1; latest is %2") + .arg(current) + .arg(kCurrentConfigSchemaVersion)}); + } + for (std::int64_t target = current + 1; target <= kCurrentConfigSchemaVersion; + ++target) { + const auto migration = m_migrations.find(static_cast(target)); + if (migration == m_migrations.end()) { + return std::unexpected(Error{ + QStringLiteral("Missing JSON migration step for target version %1") + .arg(target)}); + } + migration->second(data); + } + return {}; +} + +QString configDir() { + const QString base = qEnvironmentVariable( + "XDG_CONFIG_HOME", QDir::homePath() + QStringLiteral("/.config")); + return QDir(base).filePath(QStringLiteral("pxmodrim")); +} + +QString communityRulesFile() { + return QDir(configDir()).filePath(QStringLiteral("communityRules.json")); +} + +std::optional readGameVersion(const QString& gamePath) { + const QString path = QDir(gamePath).filePath(QStringLiteral("Version.txt")); + if (!QFileInfo::exists(path)) { + return std::nullopt; + } + auto bytes = readFileBytes(path); + if (!bytes) { + spdlog::warn("Failed to read game version from {}", path.toStdString()); + return std::nullopt; + } + return QString::fromUtf8(bytes->data(), static_cast(bytes->size())) + .trimmed(); +} + +PathConfig detectGamePaths() { + QString foundRoot; + const GamePaths found = findGamePaths(foundRoot); + + PathConfig result; + result.game = found.game.toStdString(); + result.local = found.local.toStdString(); + result.workshop = found.workshop.toStdString(); + result.config_folder = detectConfigFolder(foundRoot).toStdString(); + result.community_rules_file = communityRulesFile().toStdString(); + return result; +} + +AppConfigService::AppConfigService(ConfigService& svc) : m_svc(svc) {} + +QCoro::Task> AppConfigService::setup() { + auto loaded = m_svc.load(kConfigFile); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_cfg = std::move(*loaded); + co_return {}; +} + +const AppConfig& AppConfigService::cfg() const { + Q_ASSERT(m_cfg.has_value()); + return *m_cfg; +} + +Result AppConfigService::saveCfg() const { + if (!m_cfg) { + return {}; + } + return m_svc.save(kConfigFile, *m_cfg); +} + +} // namespace px::modrim diff --git a/src/core/config.hpp b/src/core/config.hpp new file mode 100644 index 0000000..64f1d93 --- /dev/null +++ b/src/core/config.hpp @@ -0,0 +1,208 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "core/constants.hpp" +#include "core/error.hpp" +#include "core/file_io.hpp" +#include "core/sort/config.hpp" + +namespace px::modrim { + +inline constexpr int kCurrentConfigSchemaVersion = 1; +inline constexpr std::string_view kSchemaVersionKey = "schema_version"; + +// Unknown keys are skipped so files written by newer or older builds still +// load. +inline constexpr glz::opts kJsonReadOpts{.error_on_unknown_keys = false}; +inline constexpr glz::opts kJsonPrettyOpts{.prettify = true, + .indentation_width = 2}; + +using JsonObject = glz::json_t::object_t; + +// Rewrites a raw JSON object from schema version N-1 to N. +using JsonMigration = std::function; +using JsonMigrations = std::map; + +// Version 1 is the first versioned layout, so there is nothing to rewrite yet. +inline JsonMigrations defaultJsonMigrations() { + return {{1, [](JsonObject&) {}}}; +} + +// glaze does not serialise QString, so serialised fields use std::string. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct PathConfig { + std::string game; + std::string local; + std::string workshop; + std::string config_folder; + std::string community_rules_file; + std::string no_version_warning_file; + std::string use_this_instead_file; + + bool operator==(const PathConfig&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct AppConfig { + PathConfig paths; + sort::SortSettings sort; + int max_snapshots = 10; + bool compact_mod_list = false; + std::string log_upload_endpoint = "https://paste.rs/"; + std::string launch_args; + std::string launch_wrapper; + AfterLaunch after_launch = AfterLaunch::kStay; + bool launch_confirm_errors = true; + bool launch_confirm_unsaved = true; + bool launch_confirm_running = true; + + bool operator==(const AppConfig&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// File-based repository for typed JSON config files in one directory. +// Versioned files (config.json, ui_prefs.json) carry a schema_version key and +// are migrated and rewritten on load when older. A missing, unreadable, or +// corrupt file loads as the struct defaults, with a warning. +class ConfigService { + public: + explicit ConfigService(QString configDir, + JsonMigrations migrations = defaultJsonMigrations()); + + const QString& directory() const noexcept; + + // Fails only when a versioned file cannot be migrated. + template + Result load(const QString& filename) const; + + // Like `load`, but nullopt when the file does not exist yet. + template + Result> loadExisting(const QString& filename) const; + + template + Result save(const QString& filename, const T& data) const; + + // The undecoded top-level object of a file, or {} when it cannot be read. + JsonObject loadRaw(const QString& filename) const; + + private: + QString filePath(const QString& filename) const; + static bool isVersioned(const QString& filename); + // Appends the schema marker after the struct's own members, where the legacy + // writer put it. + static std::string withSchemaMarker(std::string prettyObject); + Result> loadVersioned( + const QString& filename) const; + Result migrate(JsonObject& data, std::int64_t current) const; + + QString m_dir; + JsonMigrations m_migrations; +}; + +template +Result ConfigService::load(const QString& filename) const { + const QString path = filePath(filename); + if (!QFileInfo::exists(path)) { + return T{}; + } + + std::string text; + if (isVersioned(filename)) { + auto document = loadVersioned(filename); + if (!document) { + return std::unexpected(document.error()); + } + if (!document->has_value()) { + return T{}; + } + auto encoded = glz::write_json(**document); + if (!encoded) { + return T{}; + } + text = std::move(*encoded); + } else { + auto bytes = readFileBytes(path); + if (!bytes) { + return T{}; + } + text = std::move(*bytes); + } + + T value{}; + if (auto error = glz::read(value, text)) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + glz::format_error(error, text)); + return T{}; + } + return value; +} + +template +Result> ConfigService::loadExisting( + const QString& filename) const { + if (!QFileInfo::exists(filePath(filename))) { + return std::optional{}; + } + auto loaded = load(filename); + if (!loaded) { + return std::unexpected(loaded.error()); + } + return std::optional(std::move(*loaded)); +} + +template +Result ConfigService::save(const QString& filename, const T& data) const { + auto text = glz::write(data); + if (!text) { + return std::unexpected( + Error{QStringLiteral("Cannot encode %1").arg(filename)}); + } + if (isVersioned(filename)) { + *text = withSchemaMarker(std::move(*text)); + } + return writeFileAtomically(filePath(filename), *text); +} + +QString configDir(); +QString communityRulesFile(); + +// The game version string from Version.txt, or nullopt when it is missing or +// unreadable. +std::optional readGameVersion(const QString& gamePath); + +PathConfig detectGamePaths(); + +// Owns the AppConfig lifecycle: load on setup, save on request. +class AppConfigService { + public: + explicit AppConfigService(ConfigService& svc); + + QCoro::Task> setup(); + + // Valid only after a successful setup(). + const AppConfig& cfg() const; + + Result saveCfg() const; + + private: + ConfigService& m_svc; + std::optional m_cfg; +}; + +} // namespace px::modrim diff --git a/src/core/constants.hpp b/src/core/constants.hpp new file mode 100644 index 0000000..2cecf19 --- /dev/null +++ b/src/core/constants.hpp @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::modrim { + +inline constexpr QLatin1StringView kRimworldSteamAppId{"294100"}; +inline constexpr QLatin1StringView kDefaultMissingPackageid{ + "missing.packageid"}; + +enum class LaunchStrategy : int { kDirect = 0, kSteam = 1 }; +enum class AfterLaunch : int { kStay = 0, kMinimize = 1, kClose = 2 }; + +struct RimWorldDlc { + QLatin1StringView appId; + QLatin1StringView packageId; + QLatin1StringView name; + QLatin1StringView steamUrl; + QLatin1StringView description; +}; + +inline constexpr std::array kRimworldDlcMetadata{{ + {QLatin1StringView{"294100"}, QLatin1StringView{"ludeon.rimworld"}, + QLatin1StringView{"RimWorld"}, + QLatin1StringView{"https://store.steampowered.com/app/294100/RimWorld"}, + QLatin1StringView{"Base game"}}, + {QLatin1StringView{"1149640"}, QLatin1StringView{"ludeon.rimworld.royalty"}, + QLatin1StringView{"RimWorld - Royalty"}, + QLatin1StringView{ + "https://store.steampowered.com/app/1149640/RimWorld__Royalty"}, + QLatin1StringView{"DLC #1"}}, + {QLatin1StringView{"1392840"}, + QLatin1StringView{"ludeon.rimworld.ideology"}, + QLatin1StringView{"RimWorld - Ideology"}, + QLatin1StringView{ + "https://store.steampowered.com/app/1392840/RimWorld__Ideology"}, + QLatin1StringView{"DLC #2"}}, + {QLatin1StringView{"1826140"}, QLatin1StringView{"ludeon.rimworld.biotech"}, + QLatin1StringView{"RimWorld - Biotech"}, + QLatin1StringView{ + "https://store.steampowered.com/app/1826140/RimWorld__Biotech"}, + QLatin1StringView{"DLC #3"}}, + {QLatin1StringView{"2380740"}, QLatin1StringView{"ludeon.rimworld.anomaly"}, + QLatin1StringView{"RimWorld - Anomaly"}, + QLatin1StringView{ + "https://store.steampowered.com/app/2380740/RimWorld__Anomaly"}, + QLatin1StringView{"DLC #4"}}, + {QLatin1StringView{"3022790"}, QLatin1StringView{"ludeon.rimworld.odyssey"}, + QLatin1StringView{"RimWorld - Odyssey"}, + QLatin1StringView{ + "https://store.steampowered.com/app/3022790/RimWorld__Odyssey"}, + QLatin1StringView{"DLC #5"}}, +}}; + +inline const QStringList kRimworldPackageIds = [] { + QStringList result; + result.reserve(kRimworldDlcMetadata.size()); + for (const auto& release : kRimworldDlcMetadata) { + result.push_back(QString(release.packageId)); + } + return result; +}(); + +inline const RimWorldDlc* findRimWorldDlcByPackageId(QStringView packageId) { + for (const auto& release : kRimworldDlcMetadata) { + if (packageId == release.packageId) return &release; + } + return nullptr; +} + +} // namespace px::modrim diff --git a/src/core/context.cpp b/src/core/context.cpp new file mode 100644 index 0000000..8fdf9f5 --- /dev/null +++ b/src/core/context.cpp @@ -0,0 +1,189 @@ +#include "core/context.hpp" + +#include +#include +#include + +#include + +#include "core/downloads/downloader.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/profiler.hpp" +#include "core/providers/factory.hpp" +#include "core/services/activation_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/services/game_launcher.hpp" +#include "core/services/sort_service.hpp" + +namespace px::modrim { + +namespace { + +// Minimum valid version string shape: "X.Y" — reject anything shorter. +constexpr qsizetype kVersionMinParts = 2; + +} // namespace + +CoreContext::CoreContext(AppConfig cfg, ConfigService* configService) + : m_cfg(std::move(cfg)), m_configService(configService) { + refreshGameVersion(); + m_plugins.registerPlugin(std::make_unique()); +} + +CoreContext::~CoreContext() = default; + +std::unique_ptr CoreContext::create(AppConfig cfg, + ConfigService* configService) { + std::unique_ptr ctx( + new CoreContext(std::move(cfg), configService)); + ProfileSession session(QStringLiteral("services.create")); + Profiler& timer = session.timer(); + { + auto scope = timer(QStringLiteral("create_providers")); + ctx->m_modService = std::make_unique( + *ctx, providers::createProviders(ctx->m_cfg.paths, &ctx->m_pool)); + } + { + auto scope = timer(QStringLiteral("diagnostics_service")); + ctx->m_diagnosticsService = + std::make_unique(*ctx, ctx->m_network); + } + { + auto scope = timer(QStringLiteral("sort_service")); + ctx->m_sortService = + std::make_unique(*ctx, *ctx->m_diagnosticsService); + } + { + auto scope = timer(QStringLiteral("game_launcher")); + ctx->m_gameLauncher = std::make_unique(*ctx); + } + { + auto scope = timer(QStringLiteral("activation")); + ctx->m_activation = std::make_unique(*ctx); + } + return ctx; +} + +void CoreContext::load(QHash mods, + QStringList activeUuids) { + m_mods = std::move(mods); + m_loadedActiveUuids = activeUuids; + m_activeUuids = std::move(activeUuids); +} + +void CoreContext::setActive(QStringList uuids) { + if (m_activeUuids == uuids) { + return; + } + const QSet oldSet(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QSet newSet(uuids.cbegin(), uuids.cend()); + const bool setChanged = oldSet != newSet; + m_activeUuids = std::move(uuids); + if (setChanged) { + m_diagnosticsService->rebuild(m_activeUuids); + } else { + m_diagnosticsService->reorder(m_activeUuids); + } + m_activeStateChanged.notify(m_activeUuids); +} + +QCoro::Task> CoreContext::autoSort() { + const QSet active(m_activeUuids.cbegin(), m_activeUuids.cend()); + const QStringList deps = m_sortService->resolveMissingDependencies(active); + if (!deps.isEmpty()) { + m_activeUuids.append(deps); + m_diagnosticsService->rebuild(m_activeUuids); + m_activeStateChanged.notify(m_activeUuids); + spdlog::info("auto-sort: enabling {} missing dependencies", deps.size()); + } + + const auto started = std::chrono::steady_clock::now(); + m_activeUuids = co_await m_sortService->sortActiveMods(); + const double elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - started) + .count(); + m_diagnosticsService->reorder(m_activeUuids); + m_activeStateChanged.notify(m_activeUuids); + const int count = static_cast(m_activeUuids.size()); + spdlog::info("auto-sort: {} mods sorted in {:.0f}ms", count, elapsedMs); + co_return std::pair{count, elapsedMs}; +} + +void CoreContext::updateConfig(AppConfig cfg) { + m_cfg = std::move(cfg); + refreshGameVersion(); + m_configChanged.notify(m_cfg); +} + +void CoreContext::resetProviders(const PathConfig& paths) { + m_modService->resetProviders(providers::createProviders(paths, &m_pool)); +} + +void CoreContext::refreshGameVersion() { + if (m_cfg.paths.game.empty()) { + m_gameVersion = QStringLiteral("Unknown"); + return; + } + const auto version = + readGameVersion(QString::fromStdString(m_cfg.paths.game)); + m_gameVersion = + version && !version->isEmpty() ? *version : QStringLiteral("Unknown"); +} + +QString CoreContext::targetVersion() const { + const QStringList parts = m_gameVersion.split(QLatin1Char('.')); + if (parts.size() >= kVersionMinParts) { + return QStringLiteral("%1.%2").arg(parts[0], parts[1]); + } + return QStringLiteral("Unknown"); +} + +CollectionStats CoreContext::computeStats( + const std::optional& activeIds) const { + const QStringList& ids = activeIds ? *activeIds : m_activeUuids; + CollectionStats stats; + stats.total = m_mods.size(); + stats.active = ids.size(); + stats.inactive = stats.total - stats.active; + for (const auto& mod : m_mods) { + if (!mod->valid) { + ++stats.errors; + } + } + return stats; +} + +ConfigService& CoreContext::configService() const { + Q_ASSERT(m_configService != nullptr); + return *m_configService; +} + +DownloadManager& CoreContext::downloads() const { + auto* manager = + dynamic_cast(m_plugins.get(kDownloadsManagerName)); + Q_ASSERT(manager != nullptr); + return *manager; +} + +QCoro::Task> CoreContext::initialize() { + co_return co_await m_modService->initialize(); +} + +QCoro::Task CoreContext::close() { + m_pool.waitForDone(); + co_await m_modService->startupImpact().closeConnection(); + co_await m_modService->close(); +} + +void CoreContext::closeSync() { + m_pool.waitForDone(); + m_modService->startupImpact().closeConnectionSync(); + m_modService->closeSync(); +} + +void CoreContext::registerPlugin(std::unique_ptr plugin) { + m_plugins.registerPlugin(std::move(plugin)); +} + +} // namespace px::modrim diff --git a/src/core/context.hpp b/src/core/context.hpp new file mode 100644 index 0000000..65400a6 --- /dev/null +++ b/src/core/context.hpp @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" +#include "core/structures.hpp" + +namespace px::modrim { + +class ActivationService; +class ConfigService; +class DiagnosticsService; +class DownloadManager; +class ModService; +class SortService; + +namespace services { +class GameLauncher; +} + +// Centralised application state: mods, active load order, config and game +// version, plus the services that act on them. Services hold a reference back +// to the context, so it is built only through create() and never moved. +class CoreContext { + public: + // `config_service` is not owned and must outlive the context. + static std::unique_ptr create( + AppConfig cfg, ConfigService* configService = nullptr); + + ~CoreContext(); + CoreContext(const CoreContext&) = delete; + CoreContext& operator=(const CoreContext&) = delete; + CoreContext(CoreContext&&) = delete; + CoreContext& operator=(CoreContext&&) = delete; + + // Replaces all mods and the active list, taking ownership of the data. + void load(QHash mods, QStringList activeUuids); + void setActive(QStringList uuids); + // Enables missing dependencies, then sorts the active mods. Resolves to the + // number of active mods and the time the sort took in milliseconds. + QCoro::Task> autoSort(); + // Replaces the live config and refreshes derived values (game version). + void updateConfig(AppConfig cfg); + // Rebuilds the providers for new paths. + void resetProviders(const PathConfig& paths); + CollectionStats computeStats( + const std::optional& activeIds = std::nullopt) const; + + QCoro::Task> initialize(); + QCoro::Task close(); + // Synchronous teardown for use after the event loop has stopped. + void closeSync(); + + void registerPlugin(std::unique_ptr plugin); + + const AppConfig& config() const noexcept { return m_cfg; } + const QHash& allMods() const noexcept { + return m_mods; + } + const QStringList& activeUuids() const noexcept { return m_activeUuids; } + // Active UUIDs as last loaded from disk, unaffected by later edits. + const QStringList& loadedActiveUuids() const noexcept { + return m_loadedActiveUuids; + } + // "Unknown" when the game is not configured or its version cannot be read. + const QString& gameVersion() const noexcept { return m_gameVersion; } + // The "X.Y" part of the game version, or "Unknown". + QString targetVersion() const; + + Event& activeStateChanged() noexcept { + return m_activeStateChanged; + } + Event& configChanged() noexcept { return m_configChanged; } + + bool hasConfigService() const noexcept { return m_configService != nullptr; } + ConfigService& configService() const; + QNetworkAccessManager& network() noexcept { return m_network; } + + ModService& modService() noexcept { return *m_modService; } + DiagnosticsService& diagnosticsService() noexcept { + return *m_diagnosticsService; + } + SortService& sortService() noexcept { return *m_sortService; } + ActivationService& activation() noexcept { return *m_activation; } + services::GameLauncher& gameLauncher() noexcept { return *m_gameLauncher; } + DownloadManager& downloads() const; + PluginRegistry& plugins() noexcept { return m_plugins; } + + private: + CoreContext(AppConfig cfg, ConfigService* configService); + void refreshGameVersion(); + + AppConfig m_cfg; + ConfigService* m_configService; + QHash m_mods; + QStringList m_activeUuids; + QStringList m_loadedActiveUuids; + QString m_gameVersion; + Event m_activeStateChanged; + Event m_configChanged; + // Shared by providers and the network; declared before the services so they + // outlive them. + QThreadPool m_pool; + QNetworkAccessManager m_network; + // Services are declared before the plugins, so plugins (which disconnect from + // service events in their destructors) are destroyed first. + std::unique_ptr m_modService; + std::unique_ptr m_diagnosticsService; + std::unique_ptr m_sortService; + std::unique_ptr m_gameLauncher; + std::unique_ptr m_activation; + PluginRegistry m_plugins; +}; + +} // namespace px::modrim diff --git a/src/core/context_fwd.hpp b/src/core/context_fwd.hpp new file mode 100644 index 0000000..4be04c8 --- /dev/null +++ b/src/core/context_fwd.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace px::modrim { + +class CoreContext; + +} // namespace px::modrim diff --git a/src/core/downloads/downloader.cpp b/src/core/downloads/downloader.cpp new file mode 100644 index 0000000..14e3882 --- /dev/null +++ b/src/core/downloads/downloader.cpp @@ -0,0 +1,31 @@ +#include "core/downloads/downloader.hpp" + +#include + +#include "core/downloads/manager.hpp" + +namespace px::modrim { + +Downloader::Downloader(QString name, QString label, DownloadManager& manager) + : Plugin(std::move(name), QStringList{kDownloadsManagerName}), + m_label(std::move(label)), + m_manager(manager) {} + +const QString& Downloader::label() const noexcept { return m_label; } + +void Downloader::setup(CoreContext&) { m_manager.registerDownloader(*this); } + +QStringList Downloader::updatableIds(const QList& mods) const { + QStringList ids; + QSet seen; + for (const auto& mod : mods) { + auto id = updatableId(*mod); + if (id && !seen.contains(*id)) { + seen.insert(*id); + ids.append(std::move(*id)); + } + } + return ids; +} + +} // namespace px::modrim diff --git a/src/core/downloads/downloader.hpp b/src/core/downloads/downloader.hpp new file mode 100644 index 0000000..f60cb01 --- /dev/null +++ b/src/core/downloads/downloader.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "core/downloads/types.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" + +namespace px::modrim { + +inline const QString kDownloadsManagerName = QStringLiteral("downloads"); + +class DownloadManager; + +// A source that downloads and updates mods into the local mods folder. +// Constructing a downloader hands it to `manager`; `setup` registers it, so the +// UI only talks to the manager and a downloader can be left out freely. +class Downloader : public Plugin { + public: + // `label` is the human-readable source name shown in the UI, e.g. "Steam + // Workshop". + Downloader(QString name, QString label, DownloadManager& manager); + + const QString& label() const noexcept; + + void setup(CoreContext& ctx) override; + + // Whether a batch is currently downloading. + virtual bool isDownloading() const = 0; + // Ids of the batch currently downloading. + virtual QSet activeIds() const = 0; + // Whether `mod_id` belongs to this source. + virtual bool accepts(const QString& modId) const = 0; + // Id of `mod` in this source if PxModRim downloaded it from here. + virtual std::optional updatableId(const ListedMod& mod) const = 0; + // Unix time of the last successful sync of `mod`, if known. + virtual std::optional lastSynced(const ListedMod& mod) const = 0; + // Downloads or updates `mod_ids`. Per-item failures are reported in the + // result; an error means the whole batch was refused. + virtual QCoro::Task> downloadMods( + QStringList modIds) = 0; + virtual void cancel() = 0; + + // Distinct updatable ids among `mods`, in first-seen order. + QStringList updatableIds(const QList& mods) const; + + Event statusMessageChanged; + Event downloadProgress; + Event downloadItemStatusChanged; + Event downloadItemTitled; + // "login" -> "query" (item details) -> "run" (all items resolved). + Event downloadPhaseChanged; + Event downloadFinished; + Event busyChanged; + Event batchStarted; + + private: + QString m_label; + DownloadManager& m_manager; +}; + +} // namespace px::modrim diff --git a/src/core/downloads/manager.cpp b/src/core/downloads/manager.cpp new file mode 100644 index 0000000..5fee529 --- /dev/null +++ b/src/core/downloads/manager.cpp @@ -0,0 +1,174 @@ +#include "core/downloads/manager.hpp" + +#include +#include +#include + +namespace px::modrim { + +DownloadManager::DownloadManager() : Plugin(kDownloadsManagerName) {} + +void DownloadManager::registerDownloader(Downloader& downloader) { + m_downloaders.push_back(&downloader); + downloader.statusMessageChanged.connect( + [this](const QString& v) { statusMessageChanged.notify(v); }); + downloader.downloadProgress.connect( + [this](const DownloadProgress& v) { downloadProgress.notify(v); }); + downloader.downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& v) { + downloadItemStatusChanged.notify(v); + }); + downloader.downloadItemTitled.connect( + [this](const DownloadItemTitle& v) { downloadItemTitled.notify(v); }); + downloader.downloadPhaseChanged.connect( + [this](const QString& v) { downloadPhaseChanged.notify(v); }); + downloader.downloadFinished.connect( + [this](const DownloadResult& v) { downloadFinished.notify(v); }); + downloader.batchStarted.connect( + [this](const QStringList& v) { batchStarted.notify(v); }); + downloader.busyChanged.connect([this](bool busy) { onBusy(busy); }); +} + +void DownloadManager::onBusy(bool busy) { + if (busy) { + m_cancelled = false; + } + busyChanged.notify(isDownloading()); +} + +const std::vector& DownloadManager::downloaders() const noexcept { + return m_downloaders; +} + +bool DownloadManager::available() const noexcept { + return !m_downloaders.empty(); +} + +bool DownloadManager::isDownloading() const { + return std::ranges::any_of( + m_downloaders, [](const Downloader* d) { return d->isDownloading(); }); +} + +QSet DownloadManager::activeIds() const { + QSet ids; + for (const auto* downloader : m_downloaders) { + ids |= downloader->activeIds(); + } + return ids; +} + +Downloader* DownloadManager::sourceOf(const ListedMod& mod) const { + for (auto* downloader : m_downloaders) { + if (downloader->updatableId(mod)) { + return downloader; + } + } + return nullptr; +} + +std::optional DownloadManager::updatableId( + const ListedMod& mod) const { + const Downloader* source = sourceOf(mod); + return source ? source->updatableId(mod) : std::nullopt; +} + +QStringList DownloadManager::updatableIds( + const QList& mods) const { + QStringList ids; + QSet seen; + for (const auto* downloader : m_downloaders) { + for (const auto& id : downloader->updatableIds(mods)) { + if (!seen.contains(id)) { + seen.insert(id); + ids.append(id); + } + } + } + return ids; +} + +QHash DownloadManager::namesById( + const QList& mods) const { + QHash names; + for (const auto& mod : mods) { + if (auto id = updatableId(*mod)) { + names.insert(*id, mod->name); + } + } + return names; +} + +std::optional DownloadManager::lastSynced(const ListedMod& mod) const { + const Downloader* source = sourceOf(mod); + return source ? source->lastSynced(mod) : std::nullopt; +} + +QCoro::Task> DownloadManager::downloadMods( + QStringList modIds) { + // Ids are grouped by owning downloader in first-seen order; duplicates are + // dropped. + std::vector> groups; + QSet seen; + for (const auto& modId : modIds) { + if (seen.contains(modId)) { + continue; + } + seen.insert(modId); + + Downloader* owner = nullptr; + for (auto* downloader : m_downloaders) { + if (downloader->accepts(modId)) { + owner = downloader; + break; + } + } + if (!owner) { + co_return std::unexpected( + Error{QStringLiteral("No download source handles '%1'.").arg(modId)}); + } + auto group = std::ranges::find(groups, owner, + &std::pair::first); + if (group == groups.end()) { + groups.emplace_back(owner, QStringList{}); + group = std::prev(groups.end()); + } + group->second.append(modId); + } + if (groups.empty()) { + co_return std::unexpected( + Error{QStringLiteral("No mods selected for download.")}); + } + if (isDownloading()) { + co_return std::unexpected( + Error{QStringLiteral("A download is already running.")}); + } + + m_cancelled = false; + DownloadResult total; + for (auto& [downloader, group] : groups) { + if (m_cancelled) { + break; + } + auto result = co_await downloader->downloadMods(group); + if (!result) { + co_return std::unexpected(result.error()); + } + total.succeeded += result->succeeded; + total.failed += result->failed; + total.changed += result->changed; + } + co_return total; +} + +bool DownloadManager::cancelled() const noexcept { return m_cancelled; } + +void DownloadManager::cancel() { + m_cancelled = true; + for (auto* downloader : m_downloaders) { + if (downloader->isDownloading()) { + downloader->cancel(); + } + } +} + +} // namespace px::modrim diff --git a/src/core/downloads/manager.hpp b/src/core/downloads/manager.hpp new file mode 100644 index 0000000..5ffc2aa --- /dev/null +++ b/src/core/downloads/manager.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/downloads/downloader.hpp" +#include "core/downloads/types.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" + +namespace px::modrim { + +// The one download entry point for the UI; routes to registered downloaders. +// With no downloader registered nothing is updatable and `available()` is +// false, which is how the UI hides every download control. +class DownloadManager : public Plugin { + public: + DownloadManager(); + + // Called by each downloader from its `setup`. The downloader must outlive the + // manager's use of it. + void registerDownloader(Downloader& downloader); + + const std::vector& downloaders() const noexcept; + bool available() const noexcept; + bool isDownloading() const; + QSet activeIds() const; + + // The downloader that can update `mod`, or nullptr. + Downloader* sourceOf(const ListedMod& mod) const; + std::optional updatableId(const ListedMod& mod) const; + QStringList updatableIds(const QList& mods) const; + // Display names of installed `mods`, keyed by their source id. + QHash namesById(const QList& mods) const; + std::optional lastSynced(const ListedMod& mod) const; + + // Downloads `mod_ids`, one downloader after another. Errors before anything + // starts (unclaimed id, nothing selected, already running) are returned + // without downloading. + QCoro::Task> downloadMods(QStringList modIds); + + bool cancelled() const noexcept; + void cancel(); + + Event statusMessageChanged; + Event downloadProgress; + Event downloadItemStatusChanged; + Event downloadItemTitled; + Event downloadPhaseChanged; + Event downloadFinished; + Event busyChanged; + Event batchStarted; + + private: + void onBusy(bool busy); + + std::vector m_downloaders; + bool m_cancelled = false; +}; + +} // namespace px::modrim diff --git a/src/core/downloads/steam.cpp b/src/core/downloads/steam.cpp new file mode 100644 index 0000000..c6fce11 --- /dev/null +++ b/src/core/downloads/steam.cpp @@ -0,0 +1,646 @@ +#ifdef PX_HAS_STEAM + +#include "core/downloads/steam.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/utils.hpp" + +namespace px::modrim { +namespace { + +constexpr auto kProgressLogInterval = std::chrono::seconds(15); +constexpr auto kProgressEmitInterval = std::chrono::milliseconds(100); + +// Steam item ids are uint64; anything larger is not an item. +std::optional parseItemId(const QString& text) { + if (!isAsciiDigits(text)) { + return std::nullopt; + } + const std::string utf8 = text.toStdString(); + std::uint64_t value = 0; + const char* const last = utf8.data() + utf8.size(); + const auto [end, ec] = std::from_chars(utf8.data(), last, value); + if (ec != std::errc{} || end != last) { + return std::nullopt; + } + return value; +} + +QString itemIdText(std::uint64_t id) { + return QString::number(static_cast(id)); +} + +double unixNow() { + return std::chrono::duration( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +// Settings used to live in config.json under `workshop_` keys. +QHash legacySettingsKeys() { + QHash keys; + for (const char* field : + {"auto_update_hours", "parallel_items", "threads_per_item", "proxy", + "connect_timeout", "stall_timeout"}) { + keys.insert(QStringLiteral("workshop_") + QString::fromLatin1(field), + QString::fromLatin1(field)); + } + return keys; +} + +// The real client; pxsteamdl::Client is thread-safe, so the batch worker may +// use it directly. +class PxSteamDlClient final : public WorkshopClient { + public: + explicit PxSteamDlClient(const pxsteamdl::ClientOptions& options) + : m_client(options) {} + + std::vector download( + std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) override { + return m_client.download(ids, root, options); + } + + private: + pxsteamdl::Client m_client; +}; + +} // namespace + +QCoro::Task>> loginWithPxsteamdl( + LoginOptions options) { + co_return co_await QtConcurrent::run( + [options = + std::move(options)]() -> Result> { + pxsteamdl::ClientOptions native; + native.proxy = options.proxy; + native.connect_timeout = std::chrono::seconds(options.connectTimeout); + native.stall_timeout = std::chrono::seconds(options.stallTimeout); + try { + return std::shared_ptr( + std::make_shared(native)); + } catch (const std::exception& e) { + return std::unexpected(Error{QString::fromUtf8(e.what())}); + } + }); +} + +SteamBatch::SteamBatch(int totalItems) + : total(totalItems), + started(std::chrono::steady_clock::now()), + lastLogged(started) {} + +QStringList SteamBatch::changed() const { + QStringList result; + for (const auto& id : succeeded) { + if (bytes.value(id, {0, 0}).second > 0) { + result.append(id); + } + } + return result; +} + +DownloadProgress SteamBatch::progress() const { + // Items that report all their bytes (or 0/0 for an up-to-date copy) count as + // done before their Result arrives. + QSet finished(succeeded.begin(), succeeded.end()); + finished.unite(QSet(failed.begin(), failed.end())); + qint64 bytesDone = 0; + qint64 bytesTotal = 0; + for (auto it = bytes.cbegin(); it != bytes.cend(); ++it) { + const auto [done, size] = it.value(); + if (done >= size) { + finished.insert(it.key()); + } + bytesDone += done; + bytesTotal += size; + } + return DownloadProgress{ + .total = total, + .completed = static_cast(finished.size()), + .bytesDone = bytesDone, + .bytesTotal = bytesTotal, + }; +} + +SteamDownloader::SteamDownloader(SteamDownloaderDeps deps) + : Downloader("steam_downloader", "Steam Workshop", deps.manager), + m_config(deps.config), + m_localModsPath(std::move(deps.localModsPath)), + m_installedMods(std::move(deps.installedMods)), + m_clientFactory(std::move(deps.clientFactory)), + m_settings(&deps.config, QStringLiteral("steam_downloader"), + legacySettingsKeys()) { + m_autoTimer.setSingleShot(true); + QObject::connect(&m_autoTimer, &QTimer::timeout, &m_autoTimer, + [this] { runScheduledAutoUpdate(); }); +} + +QCoro::Task SteamDownloader::loadSyncState() { + ConfigService& config = m_config; + auto loaded = co_await QtConcurrent::run( + [&config] { return config.load(kWorkshopSyncFile); }); + if (loaded) { + m_sync = std::move(*loaded); + } else { + spdlog::warn("[workshop] cannot read sync times: {}", + loaded.error().message.toStdString()); + } +} + +QCoro::Task SteamDownloader::init(CoreContext&) { + co_await loadSyncState(); + armAutoUpdate(kAutoUpdateStartupDelay); +} + +QCoro::Task SteamDownloader::shutdown() { + m_stopping = true; + m_autoTimer.stop(); + cancel(); + if (m_batchFinished.isValid()) { + QFuture pending = m_batchFinished; + co_await pending; + } +} + +bool SteamDownloader::isDownloading() const { return m_stop.has_value(); } + +QSet SteamDownloader::activeIds() const { return m_activeIds; } + +bool SteamDownloader::accepts(const QString& modId) const { + return isAsciiDigits(modId); +} + +std::optional SteamDownloader::updatableId( + const ListedMod& mod) const { + const QString local = localPath(); + if (local.isEmpty()) { + return std::nullopt; + } + const auto& path = mod.modPath(); + if (!path) { + return std::nullopt; + } + const QFileInfo info(*path); + if (QDir::cleanPath(info.path()) != QDir::cleanPath(local)) { + return std::nullopt; + } + const auto pfid = mod.publishedFileId(); + if (!pfid || *pfid != info.fileName()) { + return std::nullopt; + } + return pfid; +} + +std::optional SteamDownloader::lastSynced(const ListedMod& mod) const { + const auto id = updatableId(mod); + return id ? syncedAt(*id) : std::nullopt; +} + +QStringList SteamDownloader::staleIds(double maxAgeS, double now) const { + const double cutoff = now - maxAgeS; + const auto syncedOrZero = [this](const QString& id) { + return syncedAt(id).value_or(0.0); + }; + QStringList stale; + for (const auto& id : updatableIds(m_installedMods())) { + if (syncedOrZero(id) < cutoff) { + stale.append(id); + } + } + std::stable_sort(stale.begin(), stale.end(), + [&](const QString& a, const QString& b) { + return syncedOrZero(a) < syncedOrZero(b); + }); + return stale; +} + +QCoro::Task> SteamDownloader::downloadMods( + QStringList modIds) { + if (m_stopping) { + co_return std::unexpected( + Error{QStringLiteral("Steam downloader is shutting down.")}); + } + if (modIds.isEmpty()) { + co_return std::unexpected( + Error{QStringLiteral("No mods selected for download.")}); + } + for (const auto& id : modIds) { + if (!parseItemId(id)) { + co_return std::unexpected(Error{QStringLiteral( + "Published file IDs must be ASCII decimal numbers within uint64.")}); + } + } + const QString root = localPath(); + if (root.isEmpty()) { + co_return std::unexpected( + Error{QStringLiteral("Local mods path is not configured.")}); + } + if (isDownloading()) { + co_return std::unexpected( + Error{QStringLiteral("A download is already running.")}); + } + + modIds.removeDuplicates(); + + auto batch = std::make_shared(static_cast(modIds.size())); + m_stop.emplace(); + QPromise completion; + completion.start(); + m_batchFinished = completion.future(); + const auto finish = qScopeGuard([&completion] { completion.finish(); }); + m_activeIds = QSet(modIds.begin(), modIds.end()); + busyChanged.notify(true); + batchStarted.notify(modIds); + spdlog::info("[workshop] downloading {} items into {} (first ids: {})", + modIds.size(), root.toStdString(), + modIds.mid(0, 5).join(QStringLiteral(", ")).toStdString()); + + try { + co_await runBatch(std::move(modIds), root, batch); + } catch (...) { + finishBatch(batch); + throw; + } + co_return finishBatch(batch); +} + +void SteamDownloader::cancel() { + if (m_stop) { + spdlog::debug("[workshop] cancel requested"); + m_stop->request_stop(); + } +} + +QString SteamDownloader::localPath() const { return m_localModsPath(); } + +std::optional SteamDownloader::syncedAt(const QString& modId) const { + const auto it = m_sync.synced.find(modId.toStdString()); + if (it == m_sync.synced.end()) { + return std::nullopt; + } + return it->second; +} + +LoginOptions SteamDownloader::wantedOptions() const { + const auto& cfg = m_settings.value(); + LoginOptions options; + const QString proxy = QString::fromStdString(cfg.proxy).trimmed(); + if (!proxy.isEmpty()) { + options.proxy = proxy.toStdString(); + } + options.connectTimeout = std::max(1, cfg.connect_timeout); + options.stallTimeout = std::max(1, cfg.stall_timeout); + return options; +} + +QCoro::Task>> +SteamDownloader::ensureClient() { + const LoginOptions options = wantedOptions(); + if (m_client && options != m_clientOptions) { + m_client.reset(); + } + if (!m_client) { + statusMessageChanged.notify(QStringLiteral(u"Logging in to Steam\u2026")); + auto loggedIn = co_await m_clientFactory(options); + if (!loggedIn) { + co_return std::unexpected(loggedIn.error()); + } + m_client = std::move(*loggedIn); + m_clientOptions = options; + } + // Returning m_client directly moved it out of the member (the session was + // then logged in again), so hand back a copy. + auto result = m_client; + co_return result; +} + +QCoro::Task SteamDownloader::runBatch(QStringList ids, QString root, + std::shared_ptr batch) { + const std::stop_token stop = m_stop->get_token(); + const auto failItems = [&](const QString& error) { + for (const auto& id : ids) { + finishItem(*batch, id, error); + } + }; + const std::filesystem::path rootPath(root.toStdWString()); + const std::error_code mkdirError = co_await QtConcurrent::run([rootPath] { + std::error_code ec; + std::filesystem::create_directories(rootPath, ec); + return ec; + }); + if (stop.stop_requested()) { + failItems(QStringLiteral("cancelled")); + co_return; + } + if (mkdirError) { + const QString message = + QStringLiteral("Cannot create %1: %2") + .arg(root, QString::fromStdString(mkdirError.message())); + spdlog::error("[workshop] {}", message.toStdString()); + statusMessageChanged.notify(message); + failItems(message); + co_return; + } + + if (!m_client) { + downloadPhaseChanged.notify(QStringLiteral("login")); + } + const auto loginStarted = std::chrono::steady_clock::now(); + auto login = co_await ensureClient(); + if (stop.stop_requested()) { + failItems(QStringLiteral("cancelled")); + co_return; + } + if (!login) { + const QString message = + QStringLiteral("Steam login failed: %1").arg(login.error().message); + spdlog::error("[workshop] {}", message.toStdString()); + statusMessageChanged.notify(message); + failItems(message); + co_return; + } + spdlog::debug("[workshop] steam session ready in {:.1f}s", + std::chrono::duration(std::chrono::steady_clock::now() - + loginStarted) + .count()); + const std::shared_ptr client = *login; + + const auto& cfg = m_settings.value(); + const int parallelItems = + std::clamp(cfg.parallel_items, 1, kSteamMaxParallelItems); + const int threadsPerItem = + std::clamp(cfg.threads_per_item, 1, kSteamMaxThreadsPerItem); + spdlog::debug( + "[workshop] client.download start: {} items, {} parallel x {} threads", + ids.size(), parallelItems, threadsPerItem); + + std::vector itemIds; + itemIds.reserve(ids.size()); + for (const auto& id : ids) { + itemIds.push_back(*parseItemId(id)); // validated by download_mods + } + + // PxSteamDL calls these on its worker threads. Their state lives on the main + // thread, so each call is posted there. + QObject* const target = &m_dispatch; + pxsteamdl::Options options; + options.parallel_items = static_cast(parallelItems); + options.threads_per_item = static_cast(threadsPerItem); + options.stop = stop; + options.on_resolved = [this, target, batch](const pxsteamdl::ItemInfo& info) { + QMetaObject::invokeMethod( + target, + [this, batch, id = itemIdText(info.item_id), + title = QString::fromStdString(info.title)] { + onResolved(*batch, id, title); + }, + Qt::QueuedConnection); + }; + options.on_progress = [this, target, batch](const pxsteamdl::Progress& p) { + QMetaObject::invokeMethod( + target, + [this, batch, id = itemIdText(p.item_id), + done = static_cast(p.unpacked_bytes), + total = static_cast(p.unpacked_total)] { + onProgress(*batch, id, done, total); + }, + Qt::QueuedConnection); + }; + + downloadPhaseChanged.notify(QStringLiteral("query")); + QFuture call = QtConcurrent::run( + [client, itemIds = std::move(itemIds), rootPath, options]() -> Outcome { + try { + return client->download(itemIds, rootPath, options); + } catch (const std::runtime_error& e) { + // Anything else propagates to the caller, as in the Python version. + const std::string what = e.what(); + return std::unexpected(what.empty() ? std::string("download failed") + : what); + } + }); + Outcome outcome = co_await QFuture(call); + + // A task that finished before the co_await above resumes this coroutine + // ahead of the callbacks it posted. This marker is queued behind them. + auto marker = std::make_shared>(); + marker->start(); + QMetaObject::invokeMethod( + target, [marker] { marker->finish(); }, Qt::QueuedConnection); + co_await marker->future(); + + if (!outcome) { + // A failed call leaves the Steam session suspect; log in again next time. + m_client.reset(); + const QString message = QString::fromStdString(outcome.error()); + failItems(message); + co_return; + } + + const auto& results = *outcome; + // A network failure leaves the Steam session suspect; log in again next time. + if (std::ranges::any_of(results, [](const pxsteamdl::Result& r) { + return r.error_kind == pxsteamdl::ErrorKind::kNetwork; + })) { + m_client.reset(); + } + + // Progress posted by the worker is queued before the completion that resumes + // this coroutine, so the item callbacks have already been handled here. + QHash byId; + for (const auto& r : results) { + byId.insert(itemIdText(r.item_id), &r); + } + for (const auto& id : ids) { + const pxsteamdl::Result* result = byId.value(id, nullptr); + if (!result) { + finishItem(*batch, id, QStringLiteral("no result")); + } else if (!result->cancelled()) { + finishItem(*batch, id, QString::fromStdString(result->error)); + } + } +} + +DownloadResult SteamDownloader::finishBatch( + const std::shared_ptr& batch) { + m_stop.reset(); + m_activeIds.clear(); + busyChanged.notify(false); + + DownloadResult result{ + .succeeded = batch->succeeded, + .failed = batch->failed, + .changed = batch->changed(), + }; + spdlog::info("[workshop] finished in {:.1f}s: {} ok, {} failed{}", + std::chrono::duration(std::chrono::steady_clock::now() - + batch->started) + .count(), + result.succeeded.size(), result.failed.size(), + result.failed.isEmpty() ? std::string() + : " (failed ids: " + + result.failed.mid(0, 20) + .join(QStringLiteral(", ")) + .toStdString() + + ")"); + recordSynced(result.succeeded); + downloadFinished.notify(result); + return result; +} + +void SteamDownloader::finishItem(SteamBatch& batch, const QString& modId, + const QString& error) { + const qint64 total = batch.bytes.value(modId, {0, 0}).second; + DownloadItemStatus status; + if (!error.isEmpty()) { + spdlog::warn("[workshop] {} failed: {}", modId.toStdString(), + error.toStdString()); + batch.failed.append(modId); + status = DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("error"), + .bytesDone = 0, + .bytesTotal = 0, + .error = error, + }; + } else { + batch.succeeded.append(modId); + batch.bytes.insert(modId, {total, total}); + status = DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("success"), + .bytesDone = total, + .bytesTotal = total, + .error = {}, + }; + } + downloadItemStatusChanged.notify(status); + downloadProgress.notify(batch.progress()); +} + +void SteamDownloader::recordSynced(const QStringList& ids) { + if (ids.isEmpty()) { + return; + } + const double now = unixNow(); + for (const auto& id : ids) { + m_sync.synced[id.toStdString()] = now; + } + if (auto saved = m_config.save(kWorkshopSyncFile, m_sync); !saved) { + spdlog::warn("[workshop] cannot save sync times: {}", + saved.error().message.toStdString()); + } +} + +void SteamDownloader::onResolved(SteamBatch& batch, const QString& modId, + const QString& title) { + ++batch.resolved; + if (!title.isEmpty()) { + downloadItemTitled.notify( + DownloadItemTitle{.modId = modId, .title = title}); + } + if (batch.resolved >= batch.total) { + downloadPhaseChanged.notify(QStringLiteral("run")); + } +} + +void SteamDownloader::onProgress(SteamBatch& batch, const QString& modId, + qint64 done, qint64 total) { + if (batch.succeeded.contains(modId) || batch.failed.contains(modId)) { + return; + } + if (!batch.bytes.contains(modId)) { + ++batch.seen; + spdlog::debug("[workshop] {} first progress: {}/{} bytes (item {} of {})", + modId.toStdString(), done, total, batch.seen, batch.total); + } + batch.bytes.insert(modId, {done, total}); + + // PxSteamDL reports per network chunk, thousands of times a second; the UI + // needs far fewer. + const auto now = std::chrono::steady_clock::now(); + if (done >= total || now - batch.lastEmitted >= kProgressEmitInterval) { + batch.lastEmitted = now; + emitItemProgress(batch, modId, done, total); + } + if (now - batch.lastLogged >= kProgressLogInterval) { + batch.lastLogged = now; + const DownloadProgress progress = batch.progress(); + spdlog::info("[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", + progress.completed, progress.total, + static_cast(progress.bytesDone) / 1048576.0, + std::chrono::duration(now - batch.started).count()); + } +} + +void SteamDownloader::emitItemProgress(SteamBatch& batch, const QString& modId, + qint64 done, qint64 total) { + downloadItemStatusChanged.notify(DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("downloading"), + .bytesDone = done, + .bytesTotal = total, + .error = {}, + }); + downloadProgress.notify(batch.progress()); +} + +QCoro::Task SteamDownloader::autoUpdateOnce() { + if (isDownloading()) { + co_return; + } + const int hours = m_settings.value().auto_update_hours; + if (hours <= 0) { + co_return; + } + const QStringList ids = staleIds(hours * 3600.0, unixNow()); + if (ids.isEmpty()) { + co_return; + } + spdlog::info("[workshop] auto-update: {} mods older than {}h", ids.size(), + hours); + const auto result = co_await downloadMods(ids); + if (!result) { + spdlog::warn("[workshop] auto-update not started: {}", + result.error().message.toStdString()); + } +} + +void SteamDownloader::armAutoUpdate(std::chrono::milliseconds delay) { + if (!m_stopping) { + m_autoTimer.start(delay); + } +} + +QCoro::Task SteamDownloader::runScheduledAutoUpdate() { + try { + co_await autoUpdateOnce(); + } catch (const std::exception& e) { + spdlog::error("[workshop] auto-update iteration failed: {}", e.what()); + } + armAutoUpdate(kAutoUpdateCheckInterval); +} + +} // namespace px::modrim + +#endif // PX_HAS_STEAM diff --git a/src/core/downloads/steam.hpp b/src/core/downloads/steam.hpp new file mode 100644 index 0000000..0d83a3a --- /dev/null +++ b/src/core/downloads/steam.hpp @@ -0,0 +1,222 @@ +#pragma once + +// Everything here talks to PxSteamDL, so the whole file exists only when Steam +// support is built. +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/config.hpp" +#include "core/downloads/downloader.hpp" +#include "core/downloads/types.hpp" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin_config.hpp" + +namespace px::modrim { + +// PxSteamDL runs one shared pool of parallel_items * threads_per_item workers. +inline constexpr int kSteamMaxParallelItems = 8; +inline constexpr int kSteamMaxThreadsPerItem = 16; + +inline constexpr std::chrono::seconds kAutoUpdateStartupDelay{45}; +inline constexpr std::chrono::seconds kAutoUpdateCheckInterval{600}; + +// In the config folder; records when each Workshop item was last synced. +inline const QString kWorkshopSyncFile = QStringLiteral("workshop_sync.json"); + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SteamSettings { + int auto_update_hours = 0; + int parallel_items = 8; + int threads_per_item = 1; + std::string proxy; + int connect_timeout = 10; + int stall_timeout = 30; + + bool operator==(const SteamSettings&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// Unix seconds of the last sync, keyed by Workshop id. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct WorkshopSyncState { + std::map synced; +}; +// NOLINTEND(readability-identifier-naming) + +// Network settings of one Steam login. An unset proxy means libcurl's +// environment. +struct LoginOptions { + std::optional proxy; + int connectTimeout = 10; + int stallTimeout = 30; + + bool operator==(const LoginOptions&) const = default; +}; + +// The part of pxsteamdl::Client that the downloader uses. A seam: tests +// substitute a fake. +class WorkshopClient { + public: + virtual ~WorkshopClient() = default; + + virtual std::vector download( + std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) = 0; +}; + +using ClientFactory = + std::function>>( + LoginOptions)>; + +// Logs in anonymously with PxSteamDL on a worker thread. +QCoro::Task>> loginWithPxsteamdl( + LoginOptions options); + +// Progress of one download batch. An item counts as complete once all its bytes +// are in, which can happen before PxSteamDL returns its Result. +struct SteamBatch { + explicit SteamBatch(int totalItems); + + int total = 0; + std::chrono::steady_clock::time_point started; + std::chrono::steady_clock::time_point lastLogged; + std::chrono::steady_clock::time_point lastEmitted; + int seen = 0; + int resolved = 0; + // Item id -> (bytes done, bytes total). + QHash> bytes; + QStringList succeeded; + QStringList failed; + + // Succeeded items that transferred data; up-to-date items are excluded. + QStringList changed() const; + DownloadProgress progress() const; +}; + +// What the downloader reads from the application. Passed in rather than taken +// from a CoreContext so the downloader can be built and tested without the +// whole application. +struct SteamDownloaderDeps { + DownloadManager& manager; + ConfigService& config; + // The configured local mods folder; empty when not set. + std::function localModsPath; + // Mods the application knows about; auto-update picks the stale ones among + // them. + std::function()> installedMods; + ClientFactory clientFactory = loginWithPxsteamdl; +}; + +// Downloads Steam Workshop items into the local mods folder via PxSteamDL. +class SteamDownloader : public Downloader { + public: + explicit SteamDownloader(SteamDownloaderDeps deps); + + QCoro::Task init(CoreContext& ctx) override; + QCoro::Task shutdown() override; + + bool isDownloading() const override; + QSet activeIds() const override; + bool accepts(const QString& modId) const override; + std::optional updatableId(const ListedMod& mod) const override; + std::optional lastSynced(const ListedMod& mod) const override; + QCoro::Task> downloadMods(QStringList modIds) override; + void cancel() override; + + PluginConfig& settings() noexcept { return m_settings; } + const PluginConfig& settings() const noexcept { + return m_settings; + } + + // Loads workshop_sync.json off the UI thread; called by init(), exposed for + // tests. + QCoro::Task loadSyncState(); + + // Ids never synced, or last synced more than `max_age_s` before `now` (unix + // seconds), oldest first. + QStringList staleIds(double maxAgeS, double now) const; + + // One auto-update pass: downloads the stale mods when auto-update is enabled + // and nothing is running. The timer started by init() runs this. + QCoro::Task autoUpdateOnce(); + + private: + // The per-item results of one PxSteamDL call, or the error text if the call + // itself failed. + using Outcome = std::expected, std::string>; + + QString localPath() const; + std::optional syncedAt(const QString& modId) const; + LoginOptions wantedOptions() const; + QCoro::Task>> ensureClient(); + + QCoro::Task runBatch(QStringList ids, QString root, + std::shared_ptr batch); + DownloadResult finishBatch(const std::shared_ptr& batch); + void finishItem(SteamBatch& batch, const QString& modId, + const QString& error); + void recordSynced(const QStringList& ids); + + void onResolved(SteamBatch& batch, const QString& modId, + const QString& title); + void onProgress(SteamBatch& batch, const QString& modId, qint64 done, + qint64 total); + void emitItemProgress(SteamBatch& batch, const QString& modId, qint64 done, + qint64 total); + + void armAutoUpdate(std::chrono::milliseconds delay); + QCoro::Task runScheduledAutoUpdate(); + + ConfigService& m_config; + std::function m_localModsPath; + std::function()> m_installedMods; + ClientFactory m_clientFactory; + PluginConfig m_settings; + WorkshopSyncState m_sync; + + std::shared_ptr m_client; + LoginOptions m_clientOptions; + + // Set while a batch is downloading. + std::optional m_stop; + QSet m_activeIds; + // Includes preparation, login, and all completion notifications. + QFuture m_batchFinished; + + // Worker-thread callbacks are posted to this object, which lives on the main + // thread. + QObject m_dispatch; + QTimer m_autoTimer; + bool m_stopping = false; +}; + +} // namespace px::modrim + +#endif // PX_HAS_STEAM diff --git a/src/core/downloads/types.hpp b/src/core/downloads/types.hpp new file mode 100644 index 0000000..a2ca741 --- /dev/null +++ b/src/core/downloads/types.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +namespace px::modrim { + +struct DownloadProgress { + int total = 0; + int completed = 0; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; +}; + +// `status` is "downloading", "success" or "error". +struct DownloadItemStatus { + QString modId; + QString status; + qint64 bytesDone = 0; + qint64 bytesTotal = 0; + QString error; +}; + +struct DownloadItemTitle { + QString modId; + QString title; +}; + +struct DownloadResult { + QStringList succeeded; + QStringList failed; + // Succeeded items that actually transferred data (others were already + // current). + QStringList changed; + + bool operator==(const DownloadResult&) const = default; +}; + +} // namespace px::modrim diff --git a/src/core/error.hpp b/src/core/error.hpp new file mode 100644 index 0000000..233cdff --- /dev/null +++ b/src/core/error.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace px::modrim { + +struct Error { + QString message; +}; + +template +using Result = std::expected; + +} // namespace px::modrim diff --git a/src/core/events.hpp b/src/core/events.hpp new file mode 100644 index 0000000..a0f7845 --- /dev/null +++ b/src/core/events.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::modrim { + +// Synchronous, single-threaded callback list. `connect` returns an id that +// `disconnect` takes back: std::function is not comparable, so the legacy +// identity check on connect is replaced by ids. `emit` runs over a snapshot, +// so handlers may connect or disconnect while an emit is in progress. +template +class Event { + public: + using Handler = std::function; + using Id = std::uint64_t; + + Id connect(Handler handler) { + const Id id = m_nextId++; + m_handlers.emplace_back(id, std::move(handler)); + return id; + } + + // Returns false when `id` is not connected. + bool disconnect(Id id) { + for (auto it = m_handlers.begin(); it != m_handlers.end(); ++it) { + if (it->first == id) { + m_handlers.erase(it); + return true; + } + } + return false; + } + + // Named `notify`: Qt defines `emit` as a macro, which would swallow a member + // named so. + void notify(const T& data) const { + const auto snapshot = m_handlers; + for (const auto& [id, handler] : snapshot) { + handler(data); + } + } + + private: + std::vector> m_handlers; + Id m_nextId = 1; +}; + +} // namespace px::modrim diff --git a/src/core/file_io.cpp b/src/core/file_io.cpp new file mode 100644 index 0000000..90cefc9 --- /dev/null +++ b/src/core/file_io.cpp @@ -0,0 +1,49 @@ +#include "core/file_io.hpp" + +#include +#include +#include +#include + +namespace px::modrim { + +Result readFileBytes(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{file.errorString()}); + } + const QByteArray bytes = file.readAll(); + return std::string(bytes.constData(), static_cast(bytes.size())); +} + +Result writeFileAtomically(const QString& path, std::string_view data) { + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + return std::unexpected( + Error{QStringLiteral("Cannot create directory for %1").arg(path)}); + } + // QSaveFile writes to a temporary file and renames it over the target on + // commit; it discards the temporary file when the commit does not happen. + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) { + return std::unexpected(Error{file.errorString()}); + } + if (file.write(data.data(), static_cast(data.size())) != + static_cast(data.size())) { + return std::unexpected(Error{file.errorString()}); + } + if (!file.commit()) { + return std::unexpected(Error{file.errorString()}); + } + return {}; +} + +Result copyFileReplacing(const QString& source, const QString& target) { + QFile::remove(target); + if (!QFile::copy(source, target)) { + return std::unexpected( + Error{QStringLiteral("Cannot copy %1 to %2").arg(source, target)}); + } + return {}; +} + +} // namespace px::modrim diff --git a/src/core/file_io.hpp b/src/core/file_io.hpp new file mode 100644 index 0000000..c0ca74b --- /dev/null +++ b/src/core/file_io.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include "core/error.hpp" + +namespace px::modrim { + +Result readFileBytes(const QString& path); + +// Writes `data` to a temporary file beside `path`, syncs it, then renames it +// over `path`, so readers see either the old or the new contents. Missing +// parent directories are created. +Result writeFileAtomically(const QString& path, std::string_view data); + +// Copies `source` to `target`, replacing `target` if it exists. +Result copyFileReplacing(const QString& source, const QString& target); + +} // namespace px::modrim diff --git a/src/core/loading.cpp b/src/core/loading.cpp new file mode 100644 index 0000000..370e9c1 --- /dev/null +++ b/src/core/loading.cpp @@ -0,0 +1,83 @@ +#include "core/loading.hpp" + +#include + +namespace px::modrim { + +LoadingState::LoadingState(QObject* parent) : QObject(parent) {} + +QStringList LoadingState::statuses() const { + QStringList result; + result.reserve(static_cast(m_stack.size())); + for (const auto& frame : m_stack) { + result.append(frame.status); + } + return result; +} + +void LoadingState::push(const QString& status, int totalSteps) { + m_stack.push_back( + Frame{.status = status, .currentStep = 0, .totalSteps = totalSteps}); + emitChanged(); +} + +void LoadingState::pop() { + if (!m_stack.empty()) { + m_stack.pop_back(); + } + if (m_stack.empty()) { + Q_EMIT finished(); + } else { + emitChanged(); + } +} + +void LoadingState::step(int amount) { + if (m_stack.empty()) { + return; + } + auto& frame = m_stack.back(); + frame.currentStep = std::min(frame.totalSteps, frame.currentStep + amount); + emitChanged(); +} + +void LoadingState::setTotal(int total) { + if (m_stack.empty()) { + return; + } + m_stack.back().totalSteps = std::max(1, total); + emitChanged(); +} + +void LoadingState::setProgress(int value) { + if (m_stack.empty()) { + return; + } + auto& frame = m_stack.back(); + frame.currentStep = std::max(0, std::min(frame.totalSteps, value)); + emitChanged(); +} + +LoadingState::TaskScope LoadingState::task(const QString& status, + int totalSteps) { + push(status, totalSteps); + return TaskScope(*this); +} + +void LoadingState::emitChanged() { + if (m_stack.empty()) { + return; + } + const auto& frame = m_stack.back(); + // Truncated through double, as the legacy float division does: 29/100 * 100 + // is 28.999... and must still report 28. + const int pct = + frame.totalSteps != 0 + ? static_cast((static_cast(frame.currentStep) / + static_cast(frame.totalSteps)) * + 100.0) + : 0; + Q_EMIT changed(frame.status, pct, frame.currentStep, frame.totalSteps); +} + +} // namespace px::modrim diff --git a/src/core/loading.hpp b/src/core/loading.hpp new file mode 100644 index 0000000..f9a6d3d --- /dev/null +++ b/src/core/loading.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::modrim { + +// Stack of progress frames for one async operation. Each frame is a subtask; +// signals describe the innermost (top) frame. +class LoadingState : public QObject { + Q_OBJECT + + public: + // Pops its frame on destruction. + class TaskScope { + public: + explicit TaskScope(LoadingState& state) noexcept : m_state(&state) {} + TaskScope(TaskScope&& other) noexcept + : m_state(std::exchange(other.m_state, nullptr)) {} + TaskScope(const TaskScope&) = delete; + TaskScope& operator=(const TaskScope&) = delete; + TaskScope& operator=(TaskScope&&) = delete; + ~TaskScope() { + if (m_state) { + m_state->pop(); + } + } + + private: + LoadingState* m_state; + }; + + explicit LoadingState(QObject* parent = nullptr); + + QStringList statuses() const; + + void push(const QString& status, int totalSteps = 100); + void pop(); + void step(int amount = 1); + void setTotal(int total); + void setProgress(int value); + + [[nodiscard]] TaskScope task(const QString& status, int totalSteps = 100); + + Q_SIGNALS: + void changed(const QString& status, int pct, int current, int total); + void finished(); + + private: + struct Frame { + QString status; + int currentStep = 0; + int totalSteps = 100; + }; + + void emitChanged(); + + std::vector m_stack; +}; + +} // namespace px::modrim diff --git a/src/core/migrator.cpp b/src/core/migrator.cpp new file mode 100644 index 0000000..efe3249 --- /dev/null +++ b/src/core/migrator.cpp @@ -0,0 +1,169 @@ +#include "core/migrator.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "core/file_io.hpp" + +namespace px::modrim { + +namespace { + +Result execute(QSqlDatabase& db, const QString& sql) { + QSqlQuery query(db); + if (!query.exec(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + return {}; +} + +// QSqlQuery runs one statement per exec, so a schema script is split first. +// Semicolons inside string literals, quoted identifiers, and comments do not +// split. CREATE TRIGGER bodies are not recognised: their inner semicolons split +// and fail. +QStringList splitStatements(const QString& script) { + QStringList statements; + QString current; + QChar quote; + bool inLineComment = false; + bool inBlockComment = false; + + const auto flush = [&statements](QString& text) { + if (const QString statement = text.trimmed(); !statement.isEmpty()) { + statements.append(statement); + } + text.clear(); + }; + + for (qsizetype i = 0; i < script.size(); ++i) { + const QChar c = script[i]; + const QChar next = i + 1 < script.size() ? script[i + 1] : QChar(); + current.append(c); + if (inLineComment) { + inLineComment = c != u'\n'; + } else if (inBlockComment) { + if (c == u'*' && next == u'/') { + inBlockComment = false; + current.append(next); + ++i; + } + } else if (!quote.isNull()) { + if (c == quote) { + if (next == quote) { + current.append(next); + ++i; + } else { + quote = QChar(); + } + } + } else if (c == u'-' && next == u'-') { + inLineComment = true; + } else if (c == u'/' && next == u'*') { + inBlockComment = true; + current.append(next); + ++i; + } else if (c == u'\'' || c == u'"' || c == u'`') { + quote = c; + } else if (c == u';') { + flush(current); + } + } + flush(current); + return statements; +} + +Result executeScript(QSqlDatabase& db, const QString& script) { + for (const auto& statement : splitStatements(script)) { + if (auto applied = execute(db, statement); !applied) { + return applied; + } + } + return {}; +} + +Result userVersion(QSqlDatabase& db) { + QSqlQuery query(db); + if (!query.exec(QStringLiteral("PRAGMA user_version"))) { + return std::unexpected(Error{query.lastError().text()}); + } + return query.next() ? query.value(0).toInt() : 0; +} + +// `path` with its last suffix replaced, as Python's Path.with_suffix does. +QString withSuffix(const QString& path, const QString& suffix) { + const QFileInfo info(path); + const QString name = info.fileName(); + const qsizetype dot = name.lastIndexOf(u'.'); + const QString stem = dot > 0 ? name.left(dot) : name; + return QDir(info.absolutePath()).filePath(stem + suffix); +} + +} // namespace + +Result ensureSchema(QSqlDatabase& db, const QString& schemaSql, + int schemaVersion, const MigrationSteps& steps, + const std::optional& backupPath, + const QString& label) { + auto currentVersion = userVersion(db); + if (!currentVersion) { + return std::unexpected(currentVersion.error()); + } + const int current = *currentVersion; + if (current >= schemaVersion) { + return {}; + } + + const auto stamp = [&db](int version) { + return execute(db, QStringLiteral("PRAGMA user_version = %1").arg(version)); + }; + + if (current == 0) { + spdlog::info("Initializing {} (version {})", label.toStdString(), + schemaVersion); + if (auto initialized = executeScript(db, schemaSql); !initialized) { + return initialized; + } + return stamp(schemaVersion); + } + + spdlog::info("Migrating {} {} -> {}", label.toStdString(), current, + schemaVersion); + if (backupPath) { + // Fold the WAL into the main file so the copy below is complete. + if (auto checkpointed = + execute(db, QStringLiteral("PRAGMA wal_checkpoint(FULL)")); + !checkpointed) { + return checkpointed; + } + const QString backup = withSuffix( + *backupPath, + QStringLiteral(".db.bak.%1").arg(QDateTime::currentSecsSinceEpoch())); + if (auto copied = copyFileReplacing(*backupPath, backup); !copied) { + return copied; + } + } + + for (int target = current + 1; target <= schemaVersion; ++target) { + if (!steps.contains(target)) { + return std::unexpected( + Error{QStringLiteral("Missing migration step for target version %1") + .arg(target)}); + } + } + for (int target = current + 1; target <= schemaVersion; ++target) { + if (auto applied = steps.at(target)(); !applied) { + return applied; + } + if (auto stamped = stamp(target); !stamped) { + return stamped; + } + } + return {}; +} + +} // namespace px::modrim diff --git a/src/core/migrator.hpp b/src/core/migrator.hpp new file mode 100644 index 0000000..d908663 --- /dev/null +++ b/src/core/migrator.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/error.hpp" + +namespace px::modrim { + +// Applies one schema step, leaving the database at the step's version. +using MigrationStep = std::function()>; + +// Keyed by target schema version. +using MigrationSteps = std::map; + +// Brings the `PRAGMA user_version` of `db` up to `schema_version`. A fresh +// database (version 0) gets `schema_sql`; an older one runs the steps in order, +// and a file copy of `backup_path` is taken first. Runs synchronously: call it +// on the thread that owns `db`. +Result ensureSchema( + QSqlDatabase& db, const QString& schemaSql, int schemaVersion, + const MigrationSteps& steps, + const std::optional& backupPath = std::nullopt, + const QString& label = QStringLiteral("schema")); + +} // namespace px::modrim diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp new file mode 100644 index 0000000..96c09b1 --- /dev/null +++ b/src/core/mod_service.cpp @@ -0,0 +1,283 @@ +#include "core/mod_service.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "core/mods_config.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/services/mod_discovery.hpp" + +namespace px::modrim { + +namespace { + +QString configFolderOf(const CoreContext& ctx) { + return QString::fromStdString(ctx.config().paths.config_folder); +} + +} // namespace + +ModService::ModService(CoreContext& ctx, providers::ProviderList providers, + std::unique_ptr metadataCache) + : m_ctx(ctx), + m_providers(std::move(providers)), + m_metadataCache(std::move(metadataCache)), + m_startupImpact(ctx) { + if (!m_metadataCache && ctx.hasConfigService()) { + m_metadataCache = std::make_unique( + services::metadataCachePath(ctx.configService().directory())); + } +} + +void ModService::resetProviders(providers::ProviderList providers) { + m_providers = std::move(providers); +} + +providers::BaseModProvider* ModService::getProvider( + const QString& providerId) const { + for (const auto& provider : m_providers) { + if (provider->providerId() == providerId) { + return provider.get(); + } + } + return nullptr; +} + +QHash ModService::providerColors() const { + QHash colors; + for (const auto& provider : m_providers) { + colors.insert(provider->providerId(), provider->color()); + } + return colors; +} + +QCoro::Task> ModService::discoverOne( + providers::BaseModProvider& provider, Profiler& timer, bool forceReparse) { + auto scope = timer(QStringLiteral("provider.%1").arg(provider.providerId())); + co_return co_await provider.discover(m_ctx.targetVersion(), &timer, + m_metadataCache.get(), forceReparse); +} + +QCoro::Task> ModService::discover(Profiler* timer, + bool forceReparse) { + // Each provider profiles into its own tree, since their discoveries + // interleave. + std::optional ownTimer; + if (!timer) { + timer = &ownTimer.emplace(); + } + auto scope = (*timer)(QStringLiteral("discover")); + + std::vector> childTimers; + std::vector>> tasks; + childTimers.reserve(m_providers.size()); + tasks.reserve(m_providers.size()); + for (const auto& provider : m_providers) { + childTimers.push_back(std::make_unique()); + tasks.push_back(discoverOne(*provider, *childTimers.back(), forceReparse)); + } + + providers::ModMap allMods; + std::optional failure; + for (std::size_t i = 0; i < tasks.size(); ++i) { + auto result = co_await tasks[i]; + timer->adopt(*childTimers[i]); + if (!result) { + if (!failure) { + failure = result.error(); + } + continue; + } + for (auto it = result->cbegin(); it != result->cend(); ++it) { + allMods.insert(it.key(), it.value()); + } + } + if (failure) { + co_return std::unexpected(*failure); + } + + if (m_metadataCache) { + auto prune = (*timer)(QStringLiteral("cache_prune")); + QStringList modPaths; + for (const auto& mod : allMods) { + if (mod->modPath()) { + modPaths.append(*mod->modPath()); + } + } + const QStringList livePaths = co_await providers::runOnPool( + nullptr, [modPaths] { return services::normalizePaths(modPaths); }); + co_await m_metadataCache->pruneDisappeared( + QSet(livePaths.cbegin(), livePaths.cend())); + } + + { + auto resolve = (*timer)(QStringLiteral("resolve_active")); + const QStringList active = + services::resolveActiveUuids(allMods, configFolderOf(m_ctx)); + m_ctx.load(std::move(allMods), active); + } + + if (ownTimer) { + spdlog::debug("Profile report:\n{}", ownTimer->render().toStdString()); + } + co_return {}; +} + +QCoro::Task> ModService::runRefreshPipeline(const QString& label, + bool forceReparse) { + Profiler timer; + auto scope = timer(QStringLiteral("mod_service.%1").arg(label)); + + Profiler discoverTimer; + Profiler diagnosticsTimer; + auto discovery = discover(&discoverTimer, forceReparse); + auto diagnostics = m_ctx.diagnosticsService().initialize(&diagnosticsTimer); + auto impact = m_startupImpact.load(); + + const auto discovered = co_await discovery; + const auto initialized = co_await diagnostics; + const auto loaded = co_await impact; + timer.adopt(discoverTimer); + timer.adopt(diagnosticsTimer); + + if (!discovered) { + co_return std::unexpected(discovered.error()); + } + if (!initialized) { + co_return std::unexpected(initialized.error()); + } + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_ctx.diagnosticsService().rebuild(m_ctx.activeUuids(), &timer); + spdlog::debug("Profile report:\n{}", timer.render().toStdString()); + modsChanged.notify(std::monostate{}); + co_return {}; +} + +std::optional ModService::snapshotsDir() const { + if (!m_ctx.hasConfigService()) { + return std::nullopt; + } + return QDir(m_ctx.configService().directory()) + .filePath(QStringLiteral("snapshots")); +} + +void ModService::snapshotCurrentConfig() { + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { + return; + } + const QString configPath = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + if (!QFileInfo(configPath).isFile()) { + return; + } + if (auto created = + createSnapshot(configPath, *snapshots, m_ctx.config().max_snapshots); + !created) { + spdlog::warn("Failed to snapshot ModsConfig.xml: {}", + created.error().message.toStdString()); + } +} + +QCoro::Task> ModService::initialize() { + snapshotCurrentConfig(); + co_return co_await runRefreshPipeline(QStringLiteral("initialize"), false); +} + +QCoro::Task> ModService::reload(bool full) { + co_return co_await runRefreshPipeline(QStringLiteral("reload"), full); +} + +QStringList ModService::getSnapshots() const { + const auto snapshots = snapshotsDir(); + return snapshots ? px::modrim::getSnapshots(*snapshots) : QStringList{}; +} + +QCoro::Task ModService::restoreSnapshot(QString snapshotPath) { + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { + spdlog::warn("Cannot restore snapshot: configuration is incomplete"); + co_return false; + } + + const QString configPath = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + auto restored = px::modrim::restoreSnapshot( + snapshotPath, configPath, *snapshots, m_ctx.config().max_snapshots); + if (!restored) { + spdlog::warn("Failed to restore snapshot: {}", + restored.error().message.toStdString()); + co_return false; + } + auto reloaded = co_await reload(); + if (!reloaded) { + spdlog::error("Reload after snapshot restore failed: {}", + reloaded.error().message.toStdString()); + co_return false; + } + co_return true; +} + +QCoro::Task ModService::saveActiveLayout(QStringList activeIds) { + const QString configFolder = configFolderOf(m_ctx); + const auto snapshots = snapshotsDir(); + if (configFolder.isEmpty() || !snapshots) { + co_return false; + } + + const auto& mods = m_ctx.allMods(); + QList packageIds; + for (const QString& uuid : activeIds) { + if (auto about = std::dynamic_pointer_cast(mods.value(uuid))) { + packageIds.append(about->packageId); + } + } + const QString configPath = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + const QString targetVersion = m_ctx.targetVersion(); + const int maxSnapshots = m_ctx.config().max_snapshots; + const QString snapshotsPath = *snapshots; + + auto written = co_await providers::runOnPool>( + nullptr, [this, configPath, snapshotsPath, targetVersion, maxSnapshots, + packageIds = std::move(packageIds)] { + QMutexLocker lock(&m_saveMutex); + const auto existing = parseModsConfig(configPath); + ModsConfig data; + data.version = targetVersion; + data.activeMods = packageIds; + data.knownExpansions = + existing ? existing->knownExpansions : QList{}; + return writeModsConfig(configPath, data, snapshotsPath, maxSnapshots); + }); + if (!written) { + spdlog::warn("Failed to save ModsConfig.xml: {}", + written.error().message.toStdString()); + co_return false; + } + co_return true; +} + +QCoro::Task ModService::close() { + if (m_metadataCache) { + co_await m_metadataCache->close(); + } +} + +void ModService::closeSync() { + // Destroying the cache closes its connection and joins its worker thread. + m_metadataCache.reset(); +} + +} // namespace px::modrim diff --git a/src/core/mod_service.hpp b/src/core/mod_service.hpp new file mode 100644 index 0000000..93d0f64 --- /dev/null +++ b/src/core/mod_service.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/profiler.hpp" +#include "core/providers/factory.hpp" +#include "core/services/metadata_cache.hpp" +#include "core/services/startup_impact_service/service.hpp" + +namespace px::modrim { + +class CoreContext; + +// Orchestrates mod discovery and lifecycle across a set of providers. +class ModService { + public: + // `ctx` is not owned and must outlive the service. Without a + // `metadata_cache`, one is created in the config folder when the context has + // a config service. + ModService(CoreContext& ctx, providers::ProviderList providers, + std::unique_ptr metadataCache = nullptr); + + startup_impact::StartupImpactService& startupImpact() noexcept { + return m_startupImpact; + } + + // Replaces the provider set. + void resetProviders(providers::ProviderList providers); + // The provider with `provider_id`, or nullptr. + providers::BaseModProvider* getProvider(const QString& providerId) const; + QHash providerColors() const; + + // Runs all providers in parallel and loads the results into the context. + // `timer` is optional; without one, the profile report goes to the debug log. + QCoro::Task> discover(Profiler* timer = nullptr, + bool forceReparse = false); + + // One-time startup work: snapshot the mod list, discover, initialize + // diagnostics, load impact. + QCoro::Task> initialize(); + // Reparses About.xml files when `full` is set. + QCoro::Task> reload(bool full = false); + + // Available mod-list snapshots, newest first. + QStringList getSnapshots() const; + // Restores a snapshot to ModsConfig.xml and reloads live state. + QCoro::Task restoreSnapshot(QString snapshotPath); + // Persists the given active UUIDs as ModsConfig.xml. + QCoro::Task saveActiveLayout(QStringList activeIds); + + QCoro::Task close(); + // Synchronous teardown for use after the event loop has stopped. + void closeSync(); + + Event modsChanged; + + private: + QCoro::Task> discoverOne( + providers::BaseModProvider& provider, Profiler& timer, bool forceReparse); + QCoro::Task> runRefreshPipeline(const QString& label, + bool forceReparse); + void snapshotCurrentConfig(); + std::optional snapshotsDir() const; + + CoreContext& m_ctx; + providers::ProviderList m_providers; + std::unique_ptr m_metadataCache; + startup_impact::StartupImpactService m_startupImpact; + // Serializes ModsConfig.xml writes; they run on the thread pool. + mutable QMutex m_saveMutex; +}; + +} // namespace px::modrim diff --git a/src/core/models/metadata/parsing.cpp b/src/core/models/metadata/parsing.cpp new file mode 100644 index 0000000..3d9d585 --- /dev/null +++ b/src/core/models/metadata/parsing.cpp @@ -0,0 +1,289 @@ +#include "core/models/metadata/parsing.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "core/constants.hpp" +#include "core/utils.hpp" +#include "core/xml.hpp" + +namespace px::modrim { +namespace { + +std::optional listText(pugi::xml_node node) { + if (!node.child("li")) { + return std::nullopt; + } + QStringList result; + for (const auto item : node.children("li")) { + auto text = rawText(item).trimmed(); + if (!text.isEmpty()) { + result.append(std::move(text)); + } + } + return result; +} + +QString scalarText(pugi::xml_node node) { + if (node.child("li")) { + return {}; + } + return rawText(node).trimmed(); +} + +pugi::xml_node versionSection(pugi::xml_node parent, + const QString& shortVersion) { + if (shortVersion.isEmpty()) { + return {}; + } + const auto prefixed = (QStringLiteral("v") + shortVersion).toUtf8(); + if (auto node = parent.child(prefixed.constData())) { + return node; + } + const auto bare = shortVersion.toUtf8(); + return parent.child(bare.constData()); +} + +void readDependencies(pugi::xml_node container, BaseRules& rules) { + for (const auto item : container.children("li")) { + if (QString::fromUtf8(item.attribute("isNull").value()) == + QStringLiteral("True")) { + continue; + } + bool hasElement = false; + for (const auto child : item.children()) { + if (child.type() == pugi::node_element) { + hasElement = true; + break; + } + } + if (!item.first_attribute() && !hasElement) { + continue; + } + DependencyMod dependency; + const auto id = rawText(item.child("packageId")).trimmed(); + if (!id.isEmpty()) { + dependency.packageId = CaseInsensitiveStr(id); + } + dependency.name = rawText(item.child("displayName")).trimmed(); + dependency.workshopUrl = rawText(item.child("workshopUrl")).trimmed(); + for (const auto alternative : + item.child("alternativePackageIds").children("li")) { + const auto text = rawText(alternative).trimmed(); + if (!text.isEmpty()) { + dependency.alternativePackageIds.add(text); + } + } + if (rules.hasDependency(dependency.packageId)) { + spdlog::warn("Duplicate dependency {}; keeping its first declaration", + dependency.packageId.str().toStdString()); + } else { + rules.dependencies.append(std::move(dependency)); + } + } +} + +void applyOfficialMetadata(AboutXmlMod& mod, const QString& declaredName) { + const auto* official = findRimWorldDlcByPackageId(mod.packageId.str()); + if (!declaredName.isEmpty() && + declaredName != QStringLiteral("Unknown Mod Name") && + declaredName != mod.packageId.str()) { + mod.name = declaredName; + } else if (official) { + if (official->appId == kRimworldSteamAppId) { + mod.name = QStringLiteral("Core"); + } else { + mod.name = + QString(official->name).mid(QStringLiteral("RimWorld - ").size()); + } + } else { + mod.name = mod.packageId.str(); + } + + if (official && official->appId != kRimworldSteamAppId) { + const CaseInsensitiveStr coreId(QStringLiteral("ludeon.rimworld")); + if (!mod.aboutRules.hasDependency(coreId)) { + const auto* core = findRimWorldDlcByPackageId(coreId.str()); + DependencyMod dependency; + dependency.packageId = coreId; + dependency.name = QString(core->name); + dependency.workshopUrl = QString(core->steamUrl); + mod.aboutRules.dependencies.append(std::move(dependency)); + } + } +} + +} // namespace + +std::shared_ptr createAboutModFromElement( + pugi::xml_node root, const QString& targetVersion, bool preferVersioned) { + auto mod = std::make_shared(); + const auto versionParts = targetVersion.split(QLatin1Char('.')); + const QString shortVersion = + versionParts.size() >= 2 + ? versionParts[0] + QLatin1Char('.') + versionParts[1] + : QString{}; + QString declaredName; + QString description; + QString versionDescription; + bool sawPackageId = false; + pugi::xml_node dependencies; + pugi::xml_node versionDependencies; + constexpr std::array kRuleTags{ + "loadBefore", "loadAfter", "incompatibleWith", "forceLoadBefore", + "forceLoadAfter"}; + constexpr std::array kVersionRuleTags{ + "loadBeforeByVersion", "loadAfterByVersion", "incompatibleWithByVersion"}; + std::array ruleLists; + std::array versionRules; + + for (const auto child : root.children()) { + if (child.type() != pugi::node_element) { + continue; + } + const QLatin1StringView tag(child.name()); + if (tag == QLatin1StringView("packageId")) { + sawPackageId = true; + auto id = scalarText(child); + if (id.isEmpty()) { + mod->valid = false; + id = QString(kDefaultMissingPackageid); + spdlog::warn("About.xml contains an unusable packageId"); + } + mod->packageId = CaseInsensitiveStr(id); + } else if (tag == QLatin1StringView("steamAppId")) { + const auto text = scalarText(child); + if (isAsciiDigits(text)) { + bool ok = false; + const auto appId = text.toLongLong(&ok); + if (ok) { + mod->steamAppId = appId; + } + } + } else if (tag == QLatin1StringView("name")) { + const auto text = scalarText(child); + if (!text.isEmpty()) declaredName = text; + } else if (tag == QLatin1StringView("description")) { + const auto text = scalarText(child); + if (!text.isEmpty()) description = text; + } else if (tag == QLatin1StringView("modVersion")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->modVersion = text; + } else if (tag == QLatin1StringView("url")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->url = text; + } else if (tag == QLatin1StringView("modIconPath")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->modIconPath = text; + } else if (tag == QLatin1StringView("author")) { + const auto text = scalarText(child); + if (!text.isEmpty()) mod->authors.append(text); + } else if (tag == QLatin1StringView("authors")) { + if (auto list = listText(child)) mod->authors.append(*list); + } else if (tag == QLatin1StringView("supportedVersions")) { + if (auto list = listText(child)) { + mod->supportedVersions = QSet(list->begin(), list->end()); + } + } else if (tag == QLatin1StringView("modDependencies")) { + dependencies = child; + } else if (tag == QLatin1StringView("modDependenciesByVersion")) { + if (preferVersioned) { + if (auto section = versionSection(child, shortVersion)) { + versionDependencies = section; + } + } + } else if (tag == QLatin1StringView("descriptionsByVersion")) { + // Descriptions select their version independently of rule preferences. + const auto text = rawText(versionSection(child, shortVersion)).trimmed(); + if (!text.isEmpty()) versionDescription = text; + } else { + for (size_t index = 0; index < kRuleTags.size(); ++index) { + if (tag == QLatin1StringView(kRuleTags[index])) { + if (auto list = listText(child)) ruleLists[index] = std::move(*list); + } + } + if (preferVersioned) { + for (size_t index = 0; index < kVersionRuleTags.size(); ++index) { + if (tag == QLatin1StringView(kVersionRuleTags[index])) { + if (auto section = versionSection(child, shortVersion)) { + versionRules[index] = section; + } + } + } + } + } + } + + if (!sawPackageId) { + mod->valid = false; + mod->packageId = CaseInsensitiveStr(QString(kDefaultMissingPackageid)); + spdlog::warn("About.xml has no packageId"); + } + if (!versionDescription.isEmpty()) { + mod->description = std::move(versionDescription); + } else { + mod->description = std::move(description); + } + for (size_t index = 0; index < versionRules.size(); ++index) { + if (auto list = listText(versionRules[index])) { + ruleLists[index] = std::move(*list); + } + } + ruleLists[0].append(ruleLists[3]); + ruleLists[1].append(ruleLists[4]); + mod->aboutRules.loadBefore = CaseInsensitiveSet(ruleLists[0]); + mod->aboutRules.loadAfter = CaseInsensitiveSet(ruleLists[1]); + mod->aboutRules.incompatibleWith = CaseInsensitiveSet(ruleLists[2]); + readDependencies(versionDependencies ? versionDependencies : dependencies, + mod->aboutRules); + applyOfficialMetadata(*mod, declaredName); + return mod; +} + +Result createListedModFromPath( + const QString& path, const QString& targetVersion, bool preferVersioned, + std::optional aboutXmlPath) { + if (!QFileInfo(path).isDir()) { + return std::unexpected( + Error{QStringLiteral("Path must be a directory: %1").arg(path)}); + } + if (!aboutXmlPath) { + aboutXmlPath = findAboutXml(path); + } + ListedModPtr mod; + if (aboutXmlPath) { + pugi::xml_document document; + if (auto loaded = loadXmlFile(document, *aboutXmlPath); loaded) { + auto about = createAboutModFromElement(document.document_element(), + targetVersion, preferVersioned); + const QFileInfo metadata(*aboutXmlPath); + if (metadata.exists()) { + about->mtime = + static_cast(metadata.lastModified().toMSecsSinceEpoch()) / + 1000.0; + } + mod = std::move(about); + } else { + spdlog::error("Unable to read mod metadata {}: {}", + aboutXmlPath->toStdString(), + loaded.error().message.toStdString()); + mod = std::make_shared(); + mod->valid = false; + } + } else { + mod = std::make_shared(); + mod->valid = false; + spdlog::warn("No About.xml found for {}", path.toStdString()); + } + if (auto assigned = mod->setModPath(path); !assigned) { + return std::unexpected(assigned.error()); + } + return mod; +} + +} // namespace px::modrim diff --git a/src/core/models/metadata/parsing.hpp b/src/core/models/metadata/parsing.hpp new file mode 100644 index 0000000..c6faf66 --- /dev/null +++ b/src/core/models/metadata/parsing.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include + +#include + +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" + +namespace px::modrim { + +// A missing or unreadable About.xml yields an invalid mod, not an error. +// Only a path that is not a directory is rejected. +Result createListedModFromPath( + const QString& path, const QString& targetVersion, + bool preferVersioned = true, + std::optional aboutXmlPath = std::nullopt); + +// Parses metadata without assigning a directory to the resulting mod. +std::shared_ptr createAboutModFromElement( + pugi::xml_node root, const QString& targetVersion, + bool preferVersioned = true); + +} // namespace px::modrim diff --git a/src/core/models/metadata/structures.cpp b/src/core/models/metadata/structures.cpp new file mode 100644 index 0000000..745bc92 --- /dev/null +++ b/src/core/models/metadata/structures.cpp @@ -0,0 +1,259 @@ +#include "core/models/metadata/structures.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/constants.hpp" + +namespace px::modrim { +namespace { + +QString pathLeaf(QStringView path) { + while (!path.isEmpty()) { + if (path.endsWith(QLatin1Char('/'))) { + path.chop(1); + } else if (path.endsWith(QLatin1StringView("/."))) { + path.chop(2); + } else { + break; + } + } + if (path == QLatin1StringView(".")) { + return {}; + } + return path.mid(path.lastIndexOf(QLatin1Char('/')) + 1).toString(); +} + +bool isWorkshopNumber(QStringView text) { + return !text.isEmpty() && std::ranges::all_of(text, [](QChar character) { + return character >= QLatin1Char('0') && character <= QLatin1Char('9'); + }); +} + +std::optional readWorkshopNumber(const QString& directory) { + QFile declaration( + QDir(directory).filePath(QStringLiteral("About/PublishedFileId.txt"))); + if (declaration.open(QIODevice::ReadOnly)) { + const QByteArray bytes = declaration.readAll(); + if (declaration.error() != QFileDevice::NoError) { + return std::nullopt; + } + QStringDecoder decoder(QStringDecoder::Utf8, + QStringConverter::Flag::Stateless); + const QString text = decoder(bytes); + if (decoder.hasError()) { + return std::nullopt; + } + const QString candidate = text.trimmed(); + if (isWorkshopNumber(candidate)) { + return candidate; + } + } else if (declaration.error() == QFileDevice::PermissionsError || + QFileInfo::exists(declaration.fileName())) { + return std::nullopt; + } + + const QString folder = pathLeaf(directory); + return isWorkshopNumber(folder) ? std::optional(folder) + : std::nullopt; +} + +} // namespace + +CaseInsensitiveStr::CaseInsensitiveStr(QStringView pid) + : m_text(pid.toString().toLower()) {} + +CaseInsensitiveSet::CaseInsensitiveSet(const QStringList& values) { + m_members.reserve(values.size()); + for (const QString& value : values) { + add(value); + } +} + +CaseInsensitiveSet::CaseInsensitiveSet(const QList& values) + : m_members(values.cbegin(), values.cend()) {} + +bool CaseInsensitiveSet::contains(QStringView value) const { + return contains(CaseInsensitiveStr(value)); +} + +bool CaseInsensitiveSet::contains(const CaseInsensitiveStr& value) const { + return m_members.contains(value); +} + +void CaseInsensitiveSet::add(QStringView value) { + add(CaseInsensitiveStr(value)); +} + +void CaseInsensitiveSet::add(const CaseInsensitiveStr& value) { + m_members.insert(value); +} + +void CaseInsensitiveSet::discard(QStringView value) { + discard(CaseInsensitiveStr(value)); +} + +void CaseInsensitiveSet::discard(const CaseInsensitiveStr& value) { + m_members.remove(value); +} + +CaseInsensitiveSet CaseInsensitiveSet::operator|( + const CaseInsensitiveSet& other) const { + CaseInsensitiveSet combined = *this; + combined.m_members.unite(other.m_members); + return combined; +} + +const DependencyMod* BaseRules::findDependency( + const CaseInsensitiveStr& packageId) const { + for (const DependencyMod& dependency : dependencies) { + if (dependency.packageId == packageId) { + return &dependency; + } + } + return nullptr; +} + +void BaseRules::setDependency(DependencyMod dependency) { + for (DependencyMod& existing : dependencies) { + if (existing.packageId == dependency.packageId) { + existing = std::move(dependency); + return; + } + } + dependencies.append(std::move(dependency)); +} + +QString ListedMod::makeIdentity() { + return QUuid::createUuid().toString(QUuid::WithoutBraces); +} + +QString ListedMod::uuid() const { return m_directory.value_or(m_identity); } + +Result ListedMod::setModPath(const QString& path) { + if (m_directory) { + return std::unexpected( + Error{QStringLiteral("Mod path already set. Cannot override.")}); + } + m_directory = path; + m_workshopIdReady = false; + m_workshopId.reset(); + return {}; +} + +std::optional ListedMod::modFolder() const { + if (!m_directory) { + return std::nullopt; + } + QString folder = pathLeaf(*m_directory); + const qsizetype extension = folder.lastIndexOf(QLatin1Char('.')); + if (extension > 0 && folder != QLatin1StringView("..")) { + folder.truncate(extension); + } + return folder; +} + +qint64 ListedMod::internalTimeTouched() const { + if (!m_directory) { + return -1; + } + const QDateTime modified = QFileInfo(*m_directory).lastModified(); + return modified.isValid() ? modified.toSecsSinceEpoch() : -1; +} + +std::optional ListedMod::publishedFileId() const { + if (!m_workshopIdReady) { + m_workshopId = + m_directory ? readWorkshopNumber(*m_directory) : std::nullopt; + m_workshopIdReady = true; + } + return m_workshopId; +} + +bool ListedMod::cSharpMod() const { + if (!m_hasAssemblies) { + bool found = false; + if (m_directory) { + QDirIterator entries( + QDir(*m_directory).filePath(QStringLiteral("Assemblies")), + QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden | + QDir::System); + found = entries.hasNext(); + } + m_hasAssemblies = found; + } + return *m_hasAssemblies; +} + +bool ListedMod::xmlPatchMod() const { + if (!m_hasPatches) { + bool found = false; + if (m_directory) { + QDirIterator entries( + QDir(*m_directory).filePath(QStringLiteral("Patches")), + QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden | + QDir::System); + while (entries.hasNext()) { + entries.next(); + const QString name = entries.fileName(); + if (name.size() > 4 && name.endsWith(QLatin1StringView(".xml"))) { + found = true; + break; + } + } + } + m_hasPatches = found; + } + return *m_hasPatches; +} + +std::optional ListedMod::previewImgPath() const { + if (m_directory) { + const QString preview = + QDir(*m_directory).filePath(QStringLiteral("About/Preview.png")); + if (QFileInfo::exists(preview)) { + return preview; + } + } + return std::nullopt; +} + +std::optional AboutXmlMod::getDlcName() const { + if (const RimWorldDlc* dlc = findRimWorldDlcByPackageId(packageId.str())) { + return QString(dlc->name); + } + return std::nullopt; +} + +bool ModsConfig::checkActiveDuplicates() const { + QSet visited; + visited.reserve(activeMods.size()); + for (const CaseInsensitiveStr& packageId : activeMods) { + if (visited.contains(packageId)) { + return true; + } + visited.insert(packageId); + } + return false; +} + +bool ModsConfig::checkExpansionsDuplicates() const { + QSet visited; + visited.reserve(knownExpansions.size()); + for (const CaseInsensitiveStr& packageId : knownExpansions) { + if (visited.contains(packageId)) { + return true; + } + visited.insert(packageId); + } + return false; +} + +} // namespace px::modrim diff --git a/src/core/models/metadata/structures.hpp b/src/core/models/metadata/structures.hpp new file mode 100644 index 0000000..9dca84f --- /dev/null +++ b/src/core/models/metadata/structures.hpp @@ -0,0 +1,296 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/error.hpp" + +namespace px::modrim { + +class CaseInsensitiveStr { + public: + CaseInsensitiveStr() = default; + explicit CaseInsensitiveStr(QStringView pid); + + const QString& str() const noexcept { return m_text; } + + friend bool operator==(const CaseInsensitiveStr&, + const CaseInsensitiveStr&) = default; + friend size_t qHash(const CaseInsensitiveStr& key, size_t seed = 0) noexcept { + return qHash(key.m_text, seed); + } + + private: + QString m_text; +}; + +class CaseInsensitiveSet { + public: + CaseInsensitiveSet() = default; + explicit CaseInsensitiveSet(const QStringList& values); + explicit CaseInsensitiveSet(const QList& values); + + bool contains(QStringView value) const; + bool contains(const CaseInsensitiveStr& value) const; + void add(QStringView value); + void add(const CaseInsensitiveStr& value); + void discard(QStringView value); + void discard(const CaseInsensitiveStr& value); + CaseInsensitiveSet operator|(const CaseInsensitiveSet& other) const; + + qsizetype size() const noexcept { return m_members.size(); } + bool empty() const noexcept { return m_members.isEmpty(); } + auto begin() const { return m_members.cbegin(); } + auto end() const { return m_members.cend(); } + + friend bool operator==(const CaseInsensitiveSet&, + const CaseInsensitiveSet&) = default; + + private: + QSet m_members; +}; + +struct DependencyMod { + QString name = QStringLiteral("Unknown Mod Name"); + CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; + QString workshopUrl; + CaseInsensitiveSet alternativePackageIds; +}; + +struct BaseRules { + CaseInsensitiveSet loadAfter; + CaseInsensitiveSet loadBefore; + CaseInsensitiveSet incompatibleWith; + QList dependencies; + + const DependencyMod* findDependency( + const CaseInsensitiveStr& packageId) const; + bool hasDependency(const CaseInsensitiveStr& packageId) const { + return findDependency(packageId) != nullptr; + } + void setDependency(DependencyMod dependency); +}; + +class ListedMod { + public: + ListedMod() = default; + ListedMod(const ListedMod&) = default; + ListedMod& operator=(const ListedMod&) = default; + virtual ~ListedMod() = default; + + QString name = QStringLiteral("Unknown Mod Name"); + bool valid = true; + QSet supportedVersions; + QString description = QStringLiteral( + "This mod is considered invalid by PxModRim and by the game itself. " + "It has no About/About.xml file, so it is most likely a leftover " + "from earlier usage (for example a removed mod whose folder stayed " + "behind)."); + QString providerId; + bool obsolete = false; + bool dbBuilderNoName = false; + + virtual QString uuid() const; + const std::optional& modPath() const noexcept { return m_directory; } + Result setModPath(const QString& path); + std::optional modFolder() const; + qint64 internalTimeTouched() const; + std::optional publishedFileId() const; + bool cSharpMod() const; + bool xmlPatchMod() const; + std::optional previewImgPath() const; + + private: + QString m_identity = makeIdentity(); + std::optional m_directory; + mutable bool m_workshopIdReady = false; + mutable std::optional m_workshopId; + mutable std::optional m_hasAssemblies; + mutable std::optional m_hasPatches; + + static QString makeIdentity(); +}; + +class AboutXmlMod : public ListedMod { + public: + CaseInsensitiveStr packageId{QStringLiteral("invalid.mod")}; + QStringList authors; + QString modVersion; + std::optional modIconPath; + qint64 steamAppId = -1; + QString url; + double mtime = 0.0; + BaseRules aboutRules; + + std::optional getDlcName() const; +}; + +class ScenarioMod : public ListedMod { + public: + QString summary; +}; + +using ListedModPtr = std::shared_ptr; + +struct ModsConfig { + QString version; + QList activeMods; + QList knownExpansions; + + bool checkActiveDuplicates() const; + bool checkExpansionsDuplicates() const; + void clearActiveMods() { activeMods.clear(); } + void clearAll() { + activeMods.clear(); + knownExpansions.clear(); + } +}; + +struct ReplacementInfo { + QString name; + QString author; + QString packageid; + QString pfid; + QStringList supportedversions; + QString source = QStringLiteral("database"); +}; + +struct CompiledDependencyData { + QHash> depsGraph; + QHash> revDepsGraph; + QSet tierZeroMods; + QSet tierOneMods; + QSet tierThreeMods; + QHash> incompatibilities; + QHash> declaredIncompatibilities; +}; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SubExternalRule { + std::variant, std::string> name = std::string{}; + std::variant, std::string> comment = std::string{}; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SubExternalBoolRule { + bool value = false; + std::variant, std::string> comment = std::string{}; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct ExternalRule { + std::map loadAfter; + std::map loadBefore; + SubExternalBoolRule loadTop; + SubExternalBoolRule loadBottom; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct ExternalRulesSchema { + std::int64_t timestamp = 0; + std::map rules; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SteamDbEntryDependency { + std::string name; + std::string url; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SteamDbEntryBlacklist { + bool value = false; + std::string comment; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SteamDbEntry { + bool unpublished = false; + std::string url; + std::string packageId; + std::variant>, std::string, + std::nullptr_t> + gameVersions = std::vector>{}; + std::string steamName; + std::string name; + std::variant, std::string, std::nullptr_t> authors = + std::string{}; + std::map, SteamDbEntryDependency>> + dependencies; + SteamDbEntryBlacklist blacklist; + std::vector> tags; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SteamDbSchema { + std::int64_t version = 0; + std::map database; +}; +// NOLINTEND(readability-identifier-naming) + +enum class WorkshopUpdateStatus { + kSuccess, + kNoWorkshopMods, + kPartial, + kFailed, +}; + +struct WorkshopUpdateResult { + WorkshopUpdateStatus status = WorkshopUpdateStatus::kFailed; + int modsChecked = 0; + int modsUpdated = 0; + QStringList failedPfids; + QStringList errors; +}; + +} // namespace px::modrim + +template <> +struct glz::to { + template + static void op(const px::modrim::CaseInsensitiveStr& value, auto&& ctx, + auto&& b, auto&& ix) { + const std::string text = value.str().toStdString(); + glz::to::template op(text, ctx, b, ix); + } +}; + +template <> +struct glz::from { + template + static void op(px::modrim::CaseInsensitiveStr& value, auto&& ctx, auto&& it, + auto&& end) { + std::string text; + glz::from::template op(text, ctx, it, end); + value = px::modrim::CaseInsensitiveStr(QString::fromStdString(text)); + } +}; diff --git a/src/core/models/view/diagnostics.hpp b/src/core/models/view/diagnostics.hpp new file mode 100644 index 0000000..1e495b5 --- /dev/null +++ b/src/core/models/view/diagnostics.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace px::modrim::view { + +// UI-agnostic diagnostics summary for one mod. +struct ModDiagnosticsView { + bool hasErrors = false; + bool hasWarnings = false; + QString errorTooltip; + QString warningTooltip; + + bool operator==(const ModDiagnosticsView&) const = default; +}; + +struct ModIssueView { + QString category; + QString categoryDisplayName; + std::optional detail; + bool isError = false; + + bool operator==(const ModIssueView&) const = default; +}; + +} // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.cpp b/src/core/models/view/sidebar.cpp new file mode 100644 index 0000000..29c56a1 --- /dev/null +++ b/src/core/models/view/sidebar.cpp @@ -0,0 +1,17 @@ +#include "core/models/view/sidebar.hpp" + +#include + +namespace px::modrim::view { + +QString providerLabel(const QString& providerId) { + static const QHash labels{ + {QStringLiteral("local"), QStringLiteral("Local")}, + {QStringLiteral("steam"), QStringLiteral("Steam Workshop")}, + {QStringLiteral("downloaded"), QStringLiteral("Steam Workshop")}, + {QStringLiteral("core"), QStringLiteral("System / Core")}, + }; + return labels.value(providerId, providerId); +} + +} // namespace px::modrim::view diff --git a/src/core/models/view/sidebar.hpp b/src/core/models/view/sidebar.hpp new file mode 100644 index 0000000..6874a94 --- /dev/null +++ b/src/core/models/view/sidebar.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include + +namespace px::modrim::view { + +// Display name of a mod provider; an unknown provider is shown by its id. +QString providerLabel(const QString& providerId); + +// A sidebar filter and the mods it shows. `count` is derived from +// `visible_uuids`. +struct SidebarEntry { + enum class Kind : std::uint8_t { + kAll, + kActive, + kInactive, + kWithErrors, + kWithWarnings, + kProvider + }; + + Kind kind = Kind::kAll; + QString label; + QSet visibleUuids; + // Only for Kind::Provider: two providers can share a label, so the id tells + // them apart. + QString providerId; + + int count() const noexcept { return static_cast(visibleUuids.size()); } +}; + +} // namespace px::modrim::view diff --git a/src/core/mods_config.cpp b/src/core/mods_config.cpp new file mode 100644 index 0000000..5c1ffef --- /dev/null +++ b/src/core/mods_config.cpp @@ -0,0 +1,323 @@ +#include "core/mods_config.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/constants.hpp" +#include "core/file_io.hpp" +#include "core/xml.hpp" + +namespace px::modrim { + +namespace { + +constexpr auto kRootTag = "ModsConfigData"; +constexpr auto kDefaultVersion = "1.5"; +constexpr unsigned kParseFlags = + pugi::parse_default | pugi::parse_comments | pugi::parse_pi; + +std::filesystem::path toFs(const QString& path) { + return std::filesystem::path(path.toStdU16String()); +} + +QString fromFs(const std::filesystem::path& path) { + return QString::fromStdU16String(path.u16string()); +} + +// Packages listed in RIMWORLD_DLC_METADATA; used when a file declares no known +// expansions. +QList dlcPackageIds() { + QList ids; + for (const auto& id : kRimworldPackageIds) { + ids.append(CaseInsensitiveStr(id)); + } + return ids; +} + +std::optional parseModsConfigBytes(std::string_view data, + const QString& source) { + pugi::xml_document doc; + if (const auto result = + doc.load_buffer(data.data(), data.size(), kParseFlags); + !result) { + spdlog::error("Failed to parse ModsConfig.xml at {}: {}", + source.toStdString(), result.description()); + return std::nullopt; + } + + const pugi::xml_node root = doc.document_element(); + const pugi::xml_node versionNode = root.child("version"); + const pugi::xml_node activeModsNode = root.child("activeMods"); + if (std::string_view(root.name()) != kRootTag || !versionNode || + !activeModsNode) { + spdlog::error("Invalid ModsConfig.xml structure at {}", + source.toStdString()); + return std::nullopt; + } + + // lxml's `text or "1.5"`: only a missing or empty text falls back to the + // default. + const QString rawVersion = rawText(versionNode); + ModsConfig config; + config.version = rawVersion.isEmpty() ? QString::fromLatin1(kDefaultVersion) + : rawVersion.trimmed(); + + for (const pugi::xml_node li : activeModsNode.children("li")) { + if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { + config.activeMods.append(CaseInsensitiveStr(text)); + } + } + for (const pugi::xml_node expansions : root.children("knownExpansions")) { + for (const pugi::xml_node li : expansions.children("li")) { + if (const QString text = rawText(li).trimmed(); !text.isEmpty()) { + config.knownExpansions.append(CaseInsensitiveStr(text)); + } + } + } + if (config.knownExpansions.isEmpty()) { + config.knownExpansions = dlcPackageIds(); + } + return config; +} + +struct SnapshotFile { + QString path; + std::int64_t mtimeNs = 0; + QString name; +}; + +// Snapshots ordered by (mtime, name), newest first. Nanosecond mtimes match the +// legacy ordering; names break ties because they embed the creation time. +QStringList snapshotFiles(const QString& snapshotsDir) { + std::vector found; + std::error_code ec; + std::filesystem::directory_iterator it(toFs(snapshotsDir), ec); + const std::filesystem::directory_iterator end; + for (; !ec && it != end; it.increment(ec)) { + const auto& entry = *it; + const QString name = fromFs(entry.path().filename()); + std::error_code entryEc; + if (!QDir::match(QStringLiteral("ModsConfig_*.xml"), name) || + !std::filesystem::is_regular_file(entry.path(), entryEc)) { + continue; + } + const auto mtime = std::filesystem::last_write_time(entry.path(), entryEc); + if (entryEc) { + continue; + } + found.push_back(SnapshotFile{ + .path = fromFs(entry.path()), + .mtimeNs = std::chrono::duration_cast( + mtime.time_since_epoch()) + .count(), + .name = name, + }); + } + + std::ranges::sort(found, [](const SnapshotFile& a, const SnapshotFile& b) { + return std::tie(a.mtimeNs, a.name) > std::tie(b.mtimeNs, b.name); + }); + QStringList paths; + for (const auto& snapshot : found) { + paths.append(snapshot.path); + } + return paths; +} + +// Removes the oldest snapshots beyond `max_snapshots` (at least one is always +// kept), never removing `preserved`. +void pruneSnapshots(const QString& snapshotsDir, int maxSnapshots, + const QString& preserved = {}) { + const QStringList snapshots = getSnapshots(snapshotsDir); + qsizetype remaining = snapshots.size() - std::max(1, maxSnapshots); + for (auto it = snapshots.crbegin(); it != snapshots.crend() && remaining > 0; + ++it) { + if (!preserved.isEmpty() && + QDir::cleanPath(*it) == QDir::cleanPath(preserved)) { + continue; + } + --remaining; + if (!QFile::remove(*it)) { + spdlog::warn("Failed to remove old snapshot {}", it->toStdString()); + } + } +} + +// UTC timestamp with microseconds, e.g. 20260922_100000_123456. +std::string snapshotTimestamp() { + using namespace std::chrono; + const auto micros = + duration_cast(system_clock::now().time_since_epoch()); + const auto seconds = floor(micros); + const auto fraction = (micros - seconds).count(); + return std::format("{:%Y%m%d_%H%M%S}_{:06}", sys_seconds{seconds}, fraction); +} + +// Replaces the children named `name` with one `li` per item. +void replaceListItems(pugi::xml_node root, const char* name, + const QList& items) { + pugi::xml_node node = root.child(name); + if (!node) { + node = root.append_child(name); + } + for (pugi::xml_node li = node.child("li"); li;) { + const pugi::xml_node next = li.next_sibling("li"); + node.remove_child(li); + li = next; + } + for (const auto& item : items) { + node.append_child("li").text().set(item.str().toUtf8().constData()); + } +} + +} // namespace + +std::optional parseModsConfig(const QString& path) { + auto data = readFileBytes(path); + if (!data) { + spdlog::warn("Failed to read ModsConfig.xml at {}: {}", path.toStdString(), + data.error().message.toStdString()); + return std::nullopt; + } + return parseModsConfigBytes(*data, path); +} + +QStringList getSnapshots(const QString& snapshotsDir) { + if (!QFileInfo::exists(snapshotsDir)) { + return {}; + } + return snapshotFiles(snapshotsDir); +} + +Result> createSnapshot(const QString& configPath, + const QString& snapshotsDir, + int maxSnapshots) { + if (!QFileInfo(configPath).isFile()) { + return std::optional{}; + } + auto configData = readFileBytes(configPath); + if (!configData) { + spdlog::warn("Failed to read {} for snapshot: {}", configPath.toStdString(), + configData.error().message.toStdString()); + return std::optional{}; + } + + const QStringList snapshots = getSnapshots(snapshotsDir); + if (!snapshots.isEmpty()) { + if (auto newest = readFileBytes(snapshots.first()); + newest && *newest == *configData) { + pruneSnapshots(snapshotsDir, maxSnapshots); + return std::optional(snapshots.first()); + } + } + + const QString timestamp = QString::fromStdString(snapshotTimestamp()); + QString snapshotPath = + QDir(snapshotsDir) + .filePath(QStringLiteral("ModsConfig_%1.xml").arg(timestamp)); + for (int counter = 1; QFileInfo::exists(snapshotPath); ++counter) { + snapshotPath = QDir(snapshotsDir) + .filePath(QStringLiteral("ModsConfig_%1_%2.xml") + .arg(timestamp) + .arg(counter, 3, 10, QLatin1Char('0'))); + } + + if (auto written = writeFileAtomically(snapshotPath, *configData); !written) { + return std::unexpected(written.error()); + } + pruneSnapshots(snapshotsDir, maxSnapshots); + spdlog::info("Snapshot created at {}", snapshotPath.toStdString()); + return std::optional(snapshotPath); +} + +Result restoreSnapshot(const QString& snapshotPath, + const QString& targetPath, + const QString& snapshotsDir, int maxSnapshots) { + auto snapshotData = readFileBytes(snapshotPath); + if (!snapshotData) { + return std::unexpected( + Error{QStringLiteral("Failed to read snapshot %1: %2") + .arg(snapshotPath, snapshotData.error().message)}); + } + if (!parseModsConfigBytes(*snapshotData, snapshotPath)) { + return std::unexpected( + Error{QStringLiteral("Snapshot is invalid or corrupted: %1") + .arg(snapshotPath)}); + } + + if (QFileInfo::exists(targetPath)) { + const int limit = std::max(1, maxSnapshots); + if (auto kept = createSnapshot(targetPath, snapshotsDir, limit + 1); + !kept) { + return std::unexpected(kept.error()); + } + pruneSnapshots(snapshotsDir, limit, snapshotPath); + } + if (auto written = writeFileAtomically(targetPath, *snapshotData); !written) { + return std::unexpected(written.error()); + } + spdlog::info("Restored snapshot {} to {}", snapshotPath.toStdString(), + targetPath.toStdString()); + return {}; +} + +Result writeModsConfig(const QString& path, const ModsConfig& data, + const QString& snapshotsDir, int maxSnapshots) { + // An existing ModsConfigData document is updated in place so unknown XML + // survives; anything else starts a fresh document. + pugi::xml_document doc; + bool reusable = false; + if (auto existing = readFileBytes(path); existing) { + reusable = + doc.load_buffer(existing->data(), existing->size(), kParseFlags) && + std::string_view(doc.document_element().name()) == kRootTag; + } + if (!reusable) { + doc.reset(); + doc.append_child(kRootTag); + } + pugi::xml_node root = doc.document_element(); + + pugi::xml_node versionNode = root.child("version"); + if (!versionNode) { + versionNode = root.append_child("version"); + } + versionNode.text().set(data.version.toUtf8().constData()); + replaceListItems(root, "activeMods", data.activeMods); + replaceListItems(root, "knownExpansions", data.knownExpansions); + + pugi::xml_node declaration = doc.prepend_child(pugi::node_declaration); + declaration.append_attribute("version") = "1.0"; + declaration.append_attribute("encoding") = "utf-8"; + + std::ostringstream out; + doc.save(out, " ", pugi::format_default, pugi::encoding_utf8); + + if (QFileInfo::exists(path)) { + if (auto kept = createSnapshot(path, snapshotsDir, maxSnapshots); !kept) { + return std::unexpected(kept.error()); + } + } + if (auto written = writeFileAtomically(path, out.view()); !written) { + return std::unexpected(written.error()); + } + spdlog::info("ModsConfig.xml written to {}", path.toStdString()); + return {}; +} + +} // namespace px::modrim diff --git a/src/core/mods_config.hpp b/src/core/mods_config.hpp new file mode 100644 index 0000000..e9435fc --- /dev/null +++ b/src/core/mods_config.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" + +namespace px::modrim { + +// Parses ModsConfig.xml. nullopt when the file is unreadable, malformed, or not +// a ModsConfig document. +std::optional parseModsConfig(const QString& path); + +// Snapshot files in `snapshots_dir`, newest first. +QStringList getSnapshots(const QString& snapshotsDir); + +// Keeps a copy of `config_path` in `snapshots_dir`. Yields the snapshot that +// holds the current contents (an existing identical one, or a new one), or +// nullopt when `config_path` is not a readable file. +Result> createSnapshot(const QString& configPath, + const QString& snapshotsDir, + int maxSnapshots = 10); + +// Replaces `target_path` with a valid snapshot, keeping the replaced file as a +// snapshot. +Result restoreSnapshot(const QString& snapshotPath, + const QString& targetPath, + const QString& snapshotsDir, + int maxSnapshots = 10); + +// Writes `data` into `path`, keeping unknown XML in an existing ModsConfigData +// document. The previous file is kept as a snapshot first. +Result writeModsConfig(const QString& path, const ModsConfig& data, + const QString& snapshotsDir, + int maxSnapshots = 10); + +} // namespace px::modrim diff --git a/src/core/organizer/db.cpp b/src/core/organizer/db.cpp new file mode 100644 index 0000000..b81ab81 --- /dev/null +++ b/src/core/organizer/db.cpp @@ -0,0 +1,818 @@ +#include "core/organizer/db.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/migrator.hpp" + +namespace px::modrim::organizer { + +namespace { + +// Byte-for-byte the legacy schema: existing user databases carry this exact +// version-1 layout. +const QString kSchema = QStringLiteral(R"sql( +CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + name TEXT NOT NULL, + collapsed INTEGER NOT NULL DEFAULT 0, + CHECK ((id = 1) = (parent_id IS NULL)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_parent_name + ON folders (parent_id, name COLLATE NOCASE); + +CREATE TABLE IF NOT EXISTS placements ( + package_id TEXT PRIMARY KEY COLLATE NOCASE, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + color TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS mod_tags ( + package_id TEXT NOT NULL COLLATE NOCASE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (package_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + position INTEGER NOT NULL, + field TEXT NOT NULL CHECK (field IN ('package_id', 'name', 'author')), + op TEXT NOT NULL CHECK (op IN ('prefix', 'contains', 'equals')), + pattern TEXT NOT NULL, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE +); + +INSERT OR IGNORE INTO folders (id, parent_id, name, collapsed) +VALUES (1, NULL, 'Root', 0); +)sql"); + +constexpr int kSchemaVersion = 1; + +const QString kPlaceSql = QStringLiteral( + "INSERT INTO placements (package_id, folder_id) VALUES (?, ?) " + "ON CONFLICT (package_id) DO UPDATE SET folder_id = excluded.folder_id"); +const QString kInsertRuleSql = QStringLiteral( + "INSERT INTO rules (position, field, op, pattern, folder_id) VALUES (?, ?, " + "?, ?, ?)"); +// Re-parents placements of the whole subtree to the root before the folders are +// deleted. +const QString kMoveSubtreeSql = QStringLiteral( + "WITH RECURSIVE subtree(id) AS (" + " SELECT ?" + " UNION ALL" + " SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id" + ") " + "UPDATE placements SET folder_id = ? WHERE folder_id IN (SELECT id FROM " + "subtree)"); + +Error errorOf(const QSqlQuery& query) { + return Error{query.lastError().text()}; +} + +Result runStatement(QSqlDatabase& db, const QString& sql, + const QVariantList& params = {}) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(errorOf(query)); + } + for (qsizetype i = 0; i < params.size(); ++i) { + query.bindValue(static_cast(i), params[i]); + } + if (!query.exec()) { + return std::unexpected(errorOf(query)); + } + return query; +} + +// Prepares `sql` once and runs it for each row. Positional values are rebound +// every time. +Result runBatch(QSqlDatabase& db, const QString& sql, + const QList& rows) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(errorOf(query)); + } + for (const QVariantList& row : rows) { + for (qsizetype i = 0; i < row.size(); ++i) { + query.bindValue(static_cast(i), row[i]); + } + if (!query.exec()) { + return std::unexpected(errorOf(query)); + } + } + return {}; +} + +// Visits each row of a SELECT. A failure part-way through is an error, not the +// end of the rows. +template +Result selectRows(QSqlDatabase& db, const QString& sql, + const QVariantList& params, Visit&& visit) { + auto query = runStatement(db, sql, params); + if (!query) { + return std::unexpected(query.error()); + } + while (query->next()) { + visit(*query); + } + if (query->lastError().isValid()) { + return std::unexpected(errorOf(*query)); + } + return {}; +} + +// Runs `body` inside one transaction: commits when it succeeds, rolls back +// otherwise. +template +auto inTransaction(QSqlDatabase& db, F&& body) { + using R = std::invoke_result_t; + if (!db.transaction()) { + return R(std::unexpected(Error{db.lastError().text()})); + } + R result = body(); + if (!result) { + db.rollback(); + return result; + } + if (!db.commit()) { + Error error{db.lastError().text()}; + db.rollback(); + return R(std::unexpected(std::move(error))); + } + return result; +} + +// Runs an UPDATE or DELETE that must touch a row; `missing` is the error when +// none matched. +Result runExpectingRow(QSqlDatabase& db, const QString& sql, + const QVariantList& params, + const QString& missing) { + auto query = runStatement(db, sql, params); + if (!query) { + return std::unexpected(query.error()); + } + if (query->numRowsAffected() == 0) { + return std::unexpected(Error{missing}); + } + return {}; +} + +QString folderMissing(qint64 folderId) { + return QStringLiteral("Folder with id %1 does not exist.").arg(folderId); +} + +QString tagMissing(qint64 tagId) { + return QStringLiteral("Tag with id %1 does not exist.").arg(tagId); +} + +Error depthLimitError() { + return Error{QStringLiteral("Folders can be nested at most %1 levels deep.") + .arg(kMaxDepth)}; +} + +Result folderDepth(QSqlDatabase& db, qint64 folderId, + std::optional movingFolder = std::nullopt) { + QSqlQuery query(db); + if (!query.prepare( + QStringLiteral("SELECT parent_id FROM folders WHERE id = ?"))) { + return std::unexpected(errorOf(query)); + } + for (int depth = 0; depth <= kMaxDepth; ++depth) { + if (folderId == movingFolder) { + return std::unexpected( + Error{QStringLiteral("A folder cannot be moved into itself.")}); + } + query.bindValue(0, folderId); + if (!query.exec()) { + return std::unexpected(errorOf(query)); + } + if (!query.next()) { + if (query.lastError().isValid()) { + return std::unexpected(errorOf(query)); + } + return std::unexpected( + Error{QStringLiteral("That folder no longer exists.")}); + } + if (query.value(0).isNull()) { + return depth; + } + folderId = query.value(0).toLongLong(); + query.finish(); + } + return std::unexpected(depthLimitError()); +} + +Result checkFolderMove(QSqlDatabase& db, qint64 folderId, + qint64 parentId) { + auto parentDepth = folderDepth(db, parentId, folderId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + auto height = runStatement( + db, + QStringLiteral("WITH RECURSIVE subtree(id, height) AS (" + " SELECT id, 1 FROM folders WHERE id = ?" + " UNION ALL" + " SELECT f.id, s.height + 1 FROM folders f" + " JOIN subtree s ON f.parent_id = s.id WHERE s.height <= ?" + ") SELECT MAX(height) FROM subtree"), + {folderId, kMaxDepth}); + if (!height) { + return std::unexpected(height.error()); + } + if (!height->next()) { + return std::unexpected(errorOf(*height)); + } + if (height->value(0).isNull()) { + return std::unexpected( + Error{QStringLiteral("That folder no longer exists.")}); + } + if (*parentDepth + height->value(0).toInt() > kMaxDepth) { + return std::unexpected(depthLimitError()); + } + return {}; +} + +Result loadState(QSqlDatabase& db) { + OrganizerState state; + + auto folders = selectRows( + db, QStringLiteral("SELECT id, parent_id, name, collapsed FROM folders"), + {}, [&state](QSqlQuery& row) { + Folder folder; + folder.id = row.value(0).toLongLong(); + if (!row.value(1).isNull()) { + folder.parentId = row.value(1).toLongLong(); + } + folder.name = row.value(2).toString(); + folder.collapsed = row.value(3).toLongLong() != 0; + state.folders.insert(folder.id, folder); + }); + if (!folders) { + return std::unexpected(folders.error()); + } + + auto placements = selectRows( + db, QStringLiteral("SELECT package_id, folder_id FROM placements"), {}, + [&state](QSqlQuery& row) { + state.placements[row.value(0).toString().toLower()] = + row.value(1).toLongLong(); + }); + if (!placements) { + return std::unexpected(placements.error()); + } + + auto tags = selectRows(db, QStringLiteral("SELECT id, name, color FROM tags"), + {}, [&state](QSqlQuery& row) { + const Tag tag{.id = row.value(0).toLongLong(), + .name = row.value(1).toString(), + .color = row.value(2).toString()}; + state.tags.insert(tag.id, tag); + }); + if (!tags) { + return std::unexpected(tags.error()); + } + + auto modTags = + selectRows(db, QStringLiteral("SELECT package_id, tag_id FROM mod_tags"), + {}, [&state](QSqlQuery& row) { + state.modTags[row.value(0).toString().toLower()].insert( + row.value(1).toLongLong()); + }); + if (!modTags) { + return std::unexpected(modTags.error()); + } + + bool corruptRule = false; + auto rules = selectRows( + db, + QStringLiteral( + "SELECT id, position, field, op, pattern, folder_id FROM rules " + "ORDER BY position ASC, id ASC"), + {}, [&state, &corruptRule](QSqlQuery& row) { + const auto field = parseRuleField(row.value(2).toString()); + const auto op = parseRuleOp(row.value(3).toString()); + if (!field || !op) { + corruptRule = true; + return; + } + state.rules.append(Rule{.id = row.value(0).toLongLong(), + .position = row.value(1).toLongLong(), + .field = *field, + .op = *op, + .pattern = row.value(4).toString(), + .folderId = row.value(5).toLongLong()}); + }); + if (!rules) { + return std::unexpected(rules.error()); + } + if (corruptRule) { + return std::unexpected(Error{QStringLiteral( + "The organizer database holds an unknown rule field or operator.")}); + } + return state; +} + +Result insertFolder(QSqlDatabase& db, qint64 parentId, + const QString& name) { + auto parentDepth = folderDepth(db, parentId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + if (*parentDepth + 1 > kMaxDepth) { + return std::unexpected(depthLimitError()); + } + auto inserted = runStatement( + db, QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), + {parentId, name}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + return Folder{.id = inserted->lastInsertId().toLongLong(), + .parentId = parentId, + .name = name, + .collapsed = false}; +} + +Result upsertPlacements(QSqlDatabase& db, const QStringList& packageIds, + qint64 folderId) { + QList rows; + for (const QString& pid : packageIds) { + rows.append({pid, folderId}); + } + return runBatch(db, kPlaceSql, rows); +} + +Result insertTag(QSqlDatabase& db, const QString& name, + const QString& color) { + auto inserted = runStatement( + db, QStringLiteral("INSERT INTO tags (name, color) VALUES (?, ?)"), + {name, color}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + return Tag{.id = inserted->lastInsertId().toLongLong(), + .name = name, + .color = color}; +} + +Result deleteFolderRows(QSqlDatabase& db, qint64 folderId) { + bool exists = false; + auto found = + selectRows(db, QStringLiteral("SELECT 1 FROM folders WHERE id = ?"), + {folderId}, [&exists](QSqlQuery&) { exists = true; }); + if (!found) { + return std::unexpected(found.error()); + } + if (!exists) { + return std::unexpected(Error{folderMissing(folderId)}); + } + auto moved = runStatement(db, kMoveSubtreeSql, {folderId, kRootId}); + if (!moved) { + return std::unexpected(moved.error()); + } + auto deleted = runStatement( + db, QStringLiteral("DELETE FROM folders WHERE id = ?"), {folderId}); + if (!deleted) { + return std::unexpected(deleted.error()); + } + return {}; +} + +Result setModTagRows(QSqlDatabase& db, const QStringList& packageIds, + const QSet& add, + const QSet& remove) { + if (!remove.isEmpty()) { + QList rows; + for (const QString& pid : packageIds) { + for (qint64 tagId : remove) { + rows.append({pid, tagId}); + } + } + if (auto removed = runBatch( + db, + QStringLiteral( + "DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?"), + rows); + !removed) { + return removed; + } + } + if (!add.isEmpty()) { + QList rows; + for (const QString& pid : packageIds) { + for (qint64 tagId : add) { + rows.append({pid, tagId}); + } + } + if (auto added = + runBatch(db, + QStringLiteral("INSERT OR IGNORE INTO mod_tags " + "(package_id, tag_id) VALUES (?, ?)"), + rows); + !added) { + return added; + } + } + return {}; +} + +Result> replaceRuleRows(QSqlDatabase& db, + const QList& specs) { + if (auto deleted = runStatement(db, QStringLiteral("DELETE FROM rules")); + !deleted) { + return std::unexpected(deleted.error()); + } + QList rules; + for (qsizetype position = 0; position < specs.size(); ++position) { + const RuleSpec& spec = specs[position]; + auto inserted = runStatement( + db, kInsertRuleSql, + {static_cast(position), ruleFieldName(spec.field).toString(), + ruleOpName(spec.op).toString(), spec.pattern, spec.folderId}); + if (!inserted) { + return std::unexpected(inserted.error()); + } + rules.append(Rule{.id = inserted->lastInsertId().toLongLong(), + .position = static_cast(position), + .field = spec.field, + .op = spec.op, + .pattern = spec.pattern, + .folderId = spec.folderId}); + } + return rules; +} + +Result appendNamedRuleRows(QSqlDatabase& db, qint64 parentId, + const QStringList& newFolders, + const QList& rules) { + if (!newFolders.isEmpty()) { + auto parentDepth = folderDepth(db, parentId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + if (*parentDepth + 1 > kMaxDepth) { + return std::unexpected(depthLimitError()); + } + } + QList folderRows; + for (const QString& name : newFolders) { + folderRows.append({parentId, name}); + } + if (auto created = runBatch( + db, + QStringLiteral("INSERT INTO folders (parent_id, name) VALUES (?, ?)"), + folderRows); + !created) { + return created; + } + + QHash folderIds; + if (auto found = selectRows( + db, + QStringLiteral("SELECT id, name FROM folders WHERE parent_id = ?"), + {parentId}, + [&folderIds](QSqlQuery& row) { + folderIds[row.value(1).toString().toCaseFolded()] = + row.value(0).toLongLong(); + }); + !found) { + return found; + } + + qint64 start = 0; + if (auto next = selectRows( + db, + QStringLiteral("SELECT COALESCE(MAX(position), -1) + 1 FROM rules"), + {}, [&start](QSqlQuery& row) { start = row.value(0).toLongLong(); }); + !next) { + return next; + } + + QList ruleRows; + for (qsizetype offset = 0; offset < rules.size(); ++offset) { + const StandardRule& rule = rules[offset]; + const auto folder = folderIds.constFind(rule.folder.toCaseFolded()); + if (folder == folderIds.cend()) { + return std::unexpected(Error{ + QStringLiteral("Folder \"%1\" does not exist.").arg(rule.folder)}); + } + ruleRows.append({start + static_cast(offset), + ruleFieldName(rule.field).toString(), + ruleOpName(rule.op).toString(), rule.pattern, + folder.value()}); + } + return runBatch(db, kInsertRuleSql, ruleRows); +} + +// Opens the connection named `name`, creating the file and schema on first use. +// Runs on the worker thread only. +Result configureConnection(QSqlDatabase& db, const QString& path) { + if (!db.open()) { + return std::unexpected(Error{db.lastError().text()}); + } + // WAL keeps readers from blocking the writer; the busy timeout absorbs brief + // contention. + for (const char* pragma : + {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON"}) { + if (auto applied = runStatement(db, QString::fromLatin1(pragma)); + !applied) { + return std::unexpected(applied.error()); + } + } + return ensureSchema(db, kSchema, kSchemaVersion, {}, path, + QStringLiteral("organizer DB")); +} + +Result openConnection(const QString& name, const QString& path) { + if (QSqlDatabase::contains(name)) { + return QSqlDatabase::database(name, false); + } + if (!QDir().mkpath(QFileInfo(path).absolutePath())) { + return std::unexpected( + Error{QStringLiteral("Cannot create the directory for %1").arg(path)}); + } + QSqlDatabase db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + db.setDatabaseName(path); + if (auto ready = configureConnection(db, path); !ready) { + // Drop the handle before removing the connection, so a retry can open it + // again. + db.close(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(name); + return std::unexpected(ready.error()); + } + return db; +} + +void discardConnection(const QString& name) { + if (!QSqlDatabase::contains(name)) { + return; + } + { + QSqlDatabase db = QSqlDatabase::database(name, false); + db.close(); + } + QSqlDatabase::removeDatabase(name); +} + +} // namespace + +QString dbPath(const QString& configDir) { + return QDir(configDir).filePath(QStringLiteral("organizer.db")); +} + +OrganizerDb::OrganizerDb(QString path) + : m_path(std::move(path)), + m_connectionName( + QStringLiteral("organizer-%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + m_pool.setMaxThreadCount(1); + m_pool.setExpiryTimeout(-1); +} + +OrganizerDb::~OrganizerDb() { + // Blocks until the worker has released the connection; the pool cannot stop + // before that. + QtConcurrent::run(&m_pool, [name = m_connectionName] { + discardConnection(name); + }).waitForFinished(); +} + +template +auto OrganizerDb::submit(F fn) { + using R = std::invoke_result_t; + return QtConcurrent::run( + &m_pool, + [name = m_connectionName, path = m_path, fn = std::move(fn)]() -> R { + auto db = openConnection(name, path); + if (!db) { + return std::unexpected(db.error()); + } + return fn(*db); + }); +} + +QCoro::Task> OrganizerDb::load() { + co_return co_await submit( + [](QSqlDatabase& db) -> Result { return loadState(db); }); +} + +QCoro::Task> OrganizerDb::createFolder(qint64 parentId, + QString name) { + co_return co_await submit( + [parentId, name](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return insertFolder(db, parentId, name); + }); + }); +} + +QCoro::Task> OrganizerDb::createFolderWith( + qint64 parentId, QString name, QStringList packageIds) { + co_return co_await submit( + [parentId, name, + pids = cleanPackageIds(packageIds)](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + auto folder = insertFolder(db, parentId, name); + if (!folder) { + return folder; + } + if (auto placed = upsertPlacements(db, pids, folder->id); !placed) { + return std::unexpected(placed.error()); + } + return folder; + }); + }); +} + +QCoro::Task> OrganizerDb::renameFolder(qint64 folderId, + QString name) { + if (folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("The root folder cannot be renamed.")}); + } + co_return co_await submit([folderId, name](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, QStringLiteral("UPDATE folders SET name = ? WHERE id = ?"), + {name, folderId}, folderMissing(folderId)); + }); + }); +} + +QCoro::Task> OrganizerDb::setParent(qint64 folderId, + qint64 parentId) { + if (folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("The root folder cannot be moved.")}); + } + co_return co_await submit([folderId, + parentId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + if (auto valid = checkFolderMove(db, folderId, parentId); !valid) { + return valid; + } + return runExpectingRow( + db, QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"), + {parentId, folderId}, folderMissing(folderId)); + }); + }); +} + +QCoro::Task> OrganizerDb::setCollapsed(qint64 folderId, + bool collapsed) { + co_return co_await submit([folderId, + collapsed](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, QStringLiteral("UPDATE folders SET collapsed = ? WHERE id = ?"), + {collapsed ? 1 : 0, folderId}, folderMissing(folderId)); + }); + }); +} + +QCoro::Task> OrganizerDb::setAllCollapsed(bool collapsed) { + co_return co_await submit([collapsed](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + auto updated = + runStatement(db, QStringLiteral("UPDATE folders SET collapsed = ?"), + {collapsed ? 1 : 0}); + if (!updated) { + return std::unexpected(updated.error()); + } + return {}; + }); + }); +} + +QCoro::Task> OrganizerDb::deleteFolder(qint64 folderId) { + if (folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("The root folder cannot be deleted.")}); + } + co_return co_await submit([folderId](QSqlDatabase& db) -> Result { + return inTransaction( + db, [&]() -> Result { return deleteFolderRows(db, folderId); }); + }); +} + +QCoro::Task> OrganizerDb::place(QStringList packageIds, + qint64 folderId) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + co_return co_await submit([pids, folderId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return upsertPlacements(db, pids, folderId); + }); + }); +} + +QCoro::Task> OrganizerDb::unplace(QStringList packageIds) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + co_return co_await submit([pids](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + QList rows; + for (const QString& pid : pids) { + rows.append({pid}); + } + return runBatch( + db, QStringLiteral("DELETE FROM placements WHERE package_id = ?"), + rows); + }); + }); +} + +QCoro::Task> OrganizerDb::createTag(QString name, QString color) { + co_return co_await submit([name, color](QSqlDatabase& db) -> Result { + return inTransaction( + db, [&]() -> Result { return insertTag(db, name, color); }); + }); +} + +QCoro::Task> OrganizerDb::updateTag(qint64 tagId, QString name, + QString color) { + co_return co_await submit([tagId, name, + color](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow( + db, + QStringLiteral("UPDATE tags SET name = ?, color = ? WHERE id = ?"), + {name, color, tagId}, tagMissing(tagId)); + }); + }); +} + +QCoro::Task> OrganizerDb::deleteTag(qint64 tagId) { + co_return co_await submit([tagId](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return runExpectingRow(db, + QStringLiteral("DELETE FROM tags WHERE id = ?"), + {tagId}, tagMissing(tagId)); + }); + }); +} + +QCoro::Task> OrganizerDb::setModTags(QStringList packageIds, + QSet add, + QSet remove) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + co_return co_await submit( + [pids, add, remove](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return setModTagRows(db, pids, add, remove); + }); + }); +} + +QCoro::Task>> OrganizerDb::replaceRules( + QList specs) { + co_return co_await submit([specs](QSqlDatabase& db) -> Result> { + return inTransaction(db, [&]() -> Result> { + return replaceRuleRows(db, specs); + }); + }); +} + +QCoro::Task> OrganizerDb::appendNamedRules( + qint64 parentId, QStringList newFolders, QList rules) { + co_return co_await submit( + [parentId, newFolders, rules](QSqlDatabase& db) -> Result { + return inTransaction(db, [&]() -> Result { + return appendNamedRuleRows(db, parentId, newFolders, rules); + }); + }); +} + +QCoro::Task OrganizerDb::close() { + co_await QtConcurrent::run( + &m_pool, [name = m_connectionName] { discardConnection(name); }); +} + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/db.hpp b/src/core/organizer/db.hpp new file mode 100644 index 0000000..426aa90 --- /dev/null +++ b/src/core/organizer/db.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "core/error.hpp" +#include "core/organizer/defaults.hpp" +#include "core/organizer/models.hpp" + +namespace px::modrim::organizer { + +// The organizer database file inside the config directory. +QString dbPath(const QString& configDir); + +// SQLite store for the organizer. Every operation runs on one worker thread +// that owns the connection, so operations are serialized in submission order +// and each is atomic. The connection opens on first use and closes on close() +// or destruction. +// Folder creation and reparenting enforce the depth limit and reject cycles +// against committed rows inside the mutation transaction. +class OrganizerDb { + public: + explicit OrganizerDb(QString path); + ~OrganizerDb(); + + OrganizerDb(const OrganizerDb&) = delete; + OrganizerDb& operator=(const OrganizerDb&) = delete; + + QCoro::Task> load(); + + QCoro::Task> createFolder(qint64 parentId, QString name); + QCoro::Task> createFolderWith(qint64 parentId, QString name, + QStringList packageIds); + QCoro::Task> renameFolder(qint64 folderId, QString name); + QCoro::Task> setParent(qint64 folderId, qint64 parentId); + QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); + QCoro::Task> setAllCollapsed(bool collapsed); + QCoro::Task> deleteFolder(qint64 folderId); + + QCoro::Task> place(QStringList packageIds, qint64 folderId); + QCoro::Task> unplace(QStringList packageIds); + + QCoro::Task> createTag(QString name, QString color); + QCoro::Task> updateTag(qint64 tagId, QString name, + QString color); + QCoro::Task> deleteTag(qint64 tagId); + QCoro::Task> setModTags(QStringList packageIds, QSet add, + QSet remove); + + QCoro::Task>> replaceRules(QList specs); + // Creates `new_folders` under `parent_id`, then appends `rules` after the + // existing ones, resolving each rule's folder by name among the children of + // `parent_id`. + QCoro::Task> appendNamedRules(qint64 parentId, + QStringList newFolders, + QList rules); + + QCoro::Task close(); + + private: + // Runs `fn(QSqlDatabase&)` on the worker, opening the connection first if + // needed. + template + auto submit(F fn); + + QString m_path; + QString m_connectionName; + // One thread, never expiring: the connection stays on the thread that created + // it. + QThreadPool m_pool; +}; + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.cpp b/src/core/organizer/defaults.cpp new file mode 100644 index 0000000..df19fe6 --- /dev/null +++ b/src/core/organizer/defaults.cpp @@ -0,0 +1,98 @@ +#include "core/organizer/defaults.hpp" + +#include + +namespace px::modrim::organizer { + +namespace { + +// Package ids come from each mod's own About.xml. Multi-category authors +// (brrainz, unlimitedhugs, bs, mlie) are matched by exact id only, and name +// heuristics such as "framework" or "performance" are avoided: they catch +// translations and unrelated content mods. Order matters (first match wins): +// libraries and performance precede the family prefixes that would swallow VE +// Framework and Dubs Performance Analyzer. + +QList exact(const QString& folder, + std::initializer_list ids) { + QList rules; + for (const char* id : ids) { + rules.append({folder, RuleField::kPackageId, RuleOp::kEquals, + QString::fromLatin1(id)}); + } + return rules; +} + +QList prefixes(const QString& folder, + std::initializer_list prefixes) { + QList rules; + for (const char* prefix : prefixes) { + rules.append({folder, RuleField::kPackageId, RuleOp::kPrefix, + QString::fromLatin1(prefix)}); + } + return rules; +} + +QList buildStandardRules() { + QList rules; + const auto append = [&rules](const QList& more) { + rules.append(more); + }; + const auto add = [&rules](StandardRule rule) { + rules.append(std::move(rule)); + }; + + append(prefixes("Official", {"ludeon.rimworld"})); + append(exact("Frameworks & Libraries", + { + "brrainz.harmony", + "unlimitedhugs.hugslib", + "erdelf.humanoidalienraces", + "oskarpotocki.vanillafactionsexpanded.core", + "imranfish.xmlextensions", + "zetrith.prepatcher", + "jecrell.jecstools", + "bs.fishery", + "smashphil.vehicleframework", + })); + append(exact("Performance", { + "bs.performance", + "krkr.rocketman", + "taranchuk.performanceoptimizer", + })); + append(prefixes("Performance", {"dubwise.dubsperformanceanalyzer"})); + append(exact("Quality of Life", { + "brrainz.achtung", + "brrainz.cameraplus", + "unlimitedhugs.allowtool", + "jaxe.rimhud", + "mehni.pickupandhaul", + "avilmask.commonsense", + "dhultgren.smarterconstruction", + "roolo.searchanddestroy", + })); + append(prefixes("Vanilla Expanded", { + "vanillaexpanded.", + "oskarpotocki.", + "vanillaracesexpanded.", + "vanillaquestsexpanded.", + "vanillastorytellersexpanded.", + })); + add({"Vanilla Expanded", RuleField::kAuthor, RuleOp::kContains, + "oskar potocki"}); + append(prefixes("Alpha Mods", {"sarg."})); + append(prefixes("Combat Extended", {"ceteam."})); + add({"Combat Extended", RuleField::kName, RuleOp::kContains, + "combat extended"}); + append(prefixes("Dubs Mods", {"dubwise."})); + return rules; +} + +} // namespace + +const QList& standardRules() { + static const QList rules = buildStandardRules(); + return rules; +} + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/defaults.hpp b/src/core/organizer/defaults.hpp new file mode 100644 index 0000000..3fceac9 --- /dev/null +++ b/src/core/organizer/defaults.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "core/organizer/models.hpp" + +namespace px::modrim::organizer { + +// A built-in auto-folder rule targeting a top-level folder by name. +struct StandardRule { + QString folder; + RuleField field = RuleField::kPackageId; + RuleOp op = RuleOp::kEquals; + QString pattern; +}; + +// Built-in rules in priority order: the first match wins. +const QList& standardRules(); + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/models.hpp b/src/core/organizer/models.hpp new file mode 100644 index 0000000..e3310fe --- /dev/null +++ b/src/core/organizer/models.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace px::modrim::organizer { + +inline constexpr qint64 kRootId = 1; +inline constexpr int kMaxDepth = 3; + +// Names match the SQL values stored in the rules table's CHECK constraints. +enum class RuleField { kPackageId, kName, kAuthor }; +enum class RuleOp { kPrefix, kContains, kEquals }; + +inline QLatin1StringView ruleFieldName(RuleField field) { + switch (field) { + case RuleField::kPackageId: + return QLatin1StringView("package_id"); + case RuleField::kName: + return QLatin1StringView("name"); + case RuleField::kAuthor: + return QLatin1StringView("author"); + } + return {}; +} + +inline QLatin1StringView ruleOpName(RuleOp op) { + switch (op) { + case RuleOp::kPrefix: + return QLatin1StringView("prefix"); + case RuleOp::kContains: + return QLatin1StringView("contains"); + case RuleOp::kEquals: + return QLatin1StringView("equals"); + } + return {}; +} + +inline std::optional parseRuleField(const QString& name) { + if (name == QLatin1StringView("package_id")) return RuleField::kPackageId; + if (name == QLatin1StringView("name")) return RuleField::kName; + if (name == QLatin1StringView("author")) return RuleField::kAuthor; + return std::nullopt; +} + +inline std::optional parseRuleOp(const QString& name) { + if (name == QLatin1StringView("prefix")) return RuleOp::kPrefix; + if (name == QLatin1StringView("contains")) return RuleOp::kContains; + if (name == QLatin1StringView("equals")) return RuleOp::kEquals; + return std::nullopt; +} + +struct Folder { + qint64 id = 0; + std::optional parentId; // empty only for ROOT_ID + QString name; + bool collapsed = false; + + friend bool operator==(const Folder&, const Folder&) = default; +}; + +struct Tag { + qint64 id = 0; + QString name; + QString color; + + friend bool operator==(const Tag&, const Tag&) = default; +}; + +struct RuleSpec { + RuleField field = RuleField::kName; + RuleOp op = RuleOp::kEquals; + QString pattern; + qint64 folderId = 0; + + friend bool operator==(const RuleSpec&, const RuleSpec&) = default; +}; + +struct Rule { + qint64 id = 0; + qint64 position = 0; + RuleField field = RuleField::kName; + RuleOp op = RuleOp::kEquals; + QString pattern; + qint64 folderId = 0; + + friend bool operator==(const Rule&, const Rule&) = default; +}; + +// Everything the organizer keeps in memory, as loaded from organizer.db. +// Package ids are lowercase keys. +struct OrganizerState { + QHash folders; + QHash placements; + QHash tags; + QHash> modTags; + QList rules; + + friend bool operator==(const OrganizerState&, + const OrganizerState&) = default; +}; + +// Lowercased, trimmed, de-duplicated package ids in first-seen order; blanks +// are dropped. +inline QStringList cleanPackageIds(const QStringList& packageIds) { + QStringList cleaned; + QSet seen; + for (const QString& raw : packageIds) { + const QString pid = raw.trimmed().toLower(); + if (!pid.isEmpty() && !seen.contains(pid)) { + seen.insert(pid); + cleaned.append(pid); + } + } + return cleaned; +} + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.cpp b/src/core/organizer/resolve.cpp new file mode 100644 index 0000000..72256b6 --- /dev/null +++ b/src/core/organizer/resolve.cpp @@ -0,0 +1,417 @@ +#include "core/organizer/resolve.hpp" + +#include +#include + +#include "core/providers/labels.hpp" + +namespace px::modrim::organizer { + +namespace { + +struct Matcher { + RuleOp op = RuleOp::kEquals; + QString pattern; // casefolded + + bool matches(const QString& value) const { + switch (op) { + case RuleOp::kPrefix: + return value.startsWith(pattern); + case RuleOp::kContains: + return value.contains(pattern); + case RuleOp::kEquals: + return value == pattern; + } + return false; + } +}; + +Matcher makeMatcher(RuleOp op, const QString& pattern) { + return Matcher{op, pattern.toCaseFolded()}; +} + +struct FieldRule { + RuleField field = RuleField::kName; + Matcher matcher; + qint64 folderId = kRootId; +}; + +// The casefolded value a rule field is matched against. +QString fieldValue(RuleField field, const QString& pid, const QString& name, + const QString& author) { + switch (field) { + case RuleField::kPackageId: + return pid.toCaseFolded(); + case RuleField::kName: + return name.toCaseFolded(); + case RuleField::kAuthor: + return author.toCaseFolded(); + } + return {}; +} + +// Manual placements win over rules; the first matching rule by position wins. +class Resolver { + public: + explicit Resolver(const OrganizerState& state) + : m_placements(state.placements) { + for (const Rule& rule : state.rules) { + if (state.folders.contains(rule.folderId) && rule.folderId != kRootId) { + m_rules.append(FieldRule{rule.field, makeMatcher(rule.op, rule.pattern), + rule.folderId}); + } + } + } + + Resolution resolve(const std::optional& packageId, + const QString& name, const QString& author) const { + if (!packageId) { + return {kRootId, Placement::kNone}; + } + const QString pid = packageId->toLower(); + if (const auto manual = m_placements.constFind(pid); + manual != m_placements.cend()) { + return {manual.value(), Placement::kManual}; + } + for (const FieldRule& rule : m_rules) { + if (rule.matcher.matches(fieldValue(rule.field, pid, name, author))) { + return {rule.folderId, Placement::kRule}; + } + } + return {kRootId, Placement::kNone}; + } + + private: + const QHash& m_placements; + QList m_rules; +}; + +bool matchesQuery(const ModLeaf& leaf, const QString& providerId, + const TreeQuery& query, const QString& text) { + if (query.status == StatusFilter::kActive && !leaf.enabled) { + return false; + } + if (query.status == StatusFilter::kInactive && leaf.enabled) { + return false; + } + if (!query.providerIds.isEmpty() && !query.providerIds.contains(providerId)) { + return false; + } + for (qint64 tagId : query.tagIds) { + if (!leaf.tagIds.contains(tagId)) { + return false; + } + } + if (text.isEmpty()) { + return true; + } + return leaf.name.trimmed().toCaseFolded().contains(text) || + (leaf.packageId && leaf.packageId->contains(text)) || + leaf.author.toCaseFolded().contains(text); +} + +// A folder with no mods counts as off, not on: "on" needs at least one enabled +// mod. +CheckState checkState(int total, int enabled) { + if (total != 0 && enabled == total) { + return CheckState::kOn; + } + if (enabled == 0) { + return CheckState::kOff; + } + return CheckState::kPartial; +} + +bool modLess(const ModLeaf& a, const ModLeaf& b) { + return std::pair(a.name.toCaseFolded(), a.uuid) < + std::pair(b.name.toCaseFolded(), b.uuid); +} + +bool folderLess(const Folder& a, const Folder& b) { + return std::pair(a.name.toCaseFolded(), a.id) < + std::pair(b.name.toCaseFolded(), b.id); +} + +// Builds the folder tree bottom-up. `visited` is shared across the whole walk, +// so a folder reachable twice is attached once. +struct TreeBuilder { + const QHash>& childrenOf; + const QHash>& allByFolder; + const QHash>& visibleByFolder; + const QSet& ruled; + bool filtering; + QSet visited; + + FolderNode build(const Folder& folder, int depth) { + visited.insert(folder.id); + FolderNode node; + node.folder = folder; + node.depth = depth; + node.hasRules = ruled.contains(folder.id); + + QList children = childrenOf.value(folder.id); + std::sort(children.begin(), children.end(), folderLess); + for (const Folder& child : children) { + if (visited.contains(child.id)) { + continue; + } + FolderNode sub = build(child, depth + 1); + node.total += sub.total; + node.enabled += sub.enabled; + node.visibleTotal += sub.visibleTotal; + if (!filtering || sub.visibleTotal != 0) { + node.children.push_back(std::move(sub)); + } + } + + const QList own = allByFolder.value(folder.id); + QList visible = visibleByFolder.value(folder.id); + std::sort(visible.begin(), visible.end(), modLess); + int ownEnabled = 0; + for (const ModLeaf& leaf : own) { + ownEnabled += leaf.enabled ? 1 : 0; + } + node.ownTotal = static_cast(own.size()); + node.ownEnabled = ownEnabled; + node.total += node.ownTotal; + node.enabled += ownEnabled; + node.visibleTotal += static_cast(visible.size()); + node.check = checkState(node.total, node.enabled); + node.mods = std::move(visible); + return node; + } +}; + +} // namespace + +bool TreeQuery::isEmpty() const { + return text.trimmed().isEmpty() && tagIds.isEmpty() && + status == StatusFilter::kAll && providerIds.isEmpty(); +} + +TreeQuery TreeFilter::query(const QString& text) const { + TreeQuery query; + query.text = text; + query.status = status; + if (providerId) { + query.providerIds.insert(*providerId); + } + if (tagId) { + query.tagIds.insert(*tagId); + } + return query; +} + +CheckState FolderNode::ownCheck() const { + return checkState(ownTotal, ownEnabled); +} + +QList FolderNode::walk() const { + QList nodes{this}; + for (const FolderNode& child : children) { + nodes += child.walk(); + } + return nodes; +} + +QList FolderNode::allMods() const { + QList modsOut; + for (const FolderNode* node : walk()) { + modsOut += node->mods; + } + return modsOut; +} + +std::optional modPackageId(const ListedMod& mod) { + if (const auto* about = dynamic_cast(&mod)) { + return about->packageId.str(); + } + return std::nullopt; +} + +QString modAuthor(const ListedMod& mod) { + if (const auto* about = dynamic_cast(&mod)) { + return about->authors.join(QStringLiteral(", ")); + } + return {}; +} + +Resolution folderFor(const OrganizerState& state, + const std::optional& packageId, + const QString& name, const QString& author) { + return Resolver(state).resolve(packageId, name, author); +} + +RulePreview previewRuleMatches(const OrganizerState& state, + const ModsByUuid& mods, + const QList& specs) { + const Resolver current(state); + // A spec with a blank pattern, an unknown folder or the root matches nothing. + // The matcher still uses the raw pattern, as the legacy preview did. + std::vector> candidates; + candidates.reserve(static_cast(specs.size())); + for (const RuleSpec& spec : specs) { + const bool usable = !spec.pattern.trimmed().isEmpty() && + state.folders.contains(spec.folderId) && + spec.folderId != kRootId; + candidates.push_back( + usable ? std::optional(FieldRule{spec.field, + makeMatcher(spec.op, spec.pattern), + spec.folderId}) + : std::nullopt); + } + + RulePreview preview; + preview.counts.assign(specs.size(), 0); + for (const ListedModPtr& mod : mods) { + const auto pid = modPackageId(*mod); + if (!pid) { + continue; + } + const QString author = modAuthor(*mod); + for (qsizetype index = 0; index < specs.size(); ++index) { + const std::optional& candidate = + candidates[static_cast(index)]; + if (!candidate || !candidate->matcher.matches(fieldValue( + candidate->field, *pid, mod->name, author))) { + continue; + } + preview.counts[index] += 1; + if (!state.placements.contains(*pid) && + current.resolve(pid, mod->name, author).folderId == kRootId) { + preview.assignable += 1; + } + break; + } + } + return preview; +} + +QList treeFilters(const ModsByUuid& mods, + const QSet& active, + const OrganizerState* state) { + const int total = static_cast(mods.size()); + int enabled = 0; + QHash perProvider; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + enabled += active.contains(it.key()) ? 1 : 0; + perProvider[it.value()->providerId] += 1; + } + + const auto make = [](QString key, QString label, int count) { + TreeFilter filter; + filter.key = std::move(key); + filter.label = std::move(label); + filter.count = count; + return filter; + }; + + TreeFilter statusActive = + make(QStringLiteral("active"), QStringLiteral("Active"), enabled); + statusActive.status = StatusFilter::kActive; + TreeFilter statusInactive = make(QStringLiteral("inactive"), + QStringLiteral("Inactive"), total - enabled); + statusInactive.status = StatusFilter::kInactive; + QList filters{ + make(QStringLiteral("all"), QStringLiteral("All"), total), + std::move(statusActive), std::move(statusInactive)}; + + std::vector> labelled; // (label, provider id) + for (auto it = perProvider.cbegin(); it != perProvider.cend(); ++it) { + labelled.emplace_back(providerLabel(it.key()), it.key()); + } + std::sort(labelled.begin(), labelled.end()); + for (const auto& [label, pid] : labelled) { + TreeFilter provider = make(QStringLiteral("provider:%1").arg(pid), label, + perProvider.value(pid)); + provider.providerId = pid; + filters.append(std::move(provider)); + } + + if (state && !state->tags.isEmpty()) { + QHash tagCounts; + for (const auto& [id, tag] : state->tags.asKeyValueRange()) { + tagCounts.insert(id, 0); + } + for (const ListedModPtr& mod : mods) { + const auto pid = modPackageId(*mod); + if (!pid) { + continue; + } + for (qint64 tagId : state->modTags.value(*pid)) { + if (tagCounts.contains(tagId)) { + tagCounts[tagId] += 1; + } + } + } + + std::vector sortedTags; + for (const Tag& tag : state->tags) { + sortedTags.push_back(tag); + } + std::sort(sortedTags.begin(), sortedTags.end(), + [](const Tag& a, const Tag& b) { + return std::pair(a.name.toCaseFolded(), a.id) < + std::pair(b.name.toCaseFolded(), b.id); + }); + for (const Tag& tag : sortedTags) { + TreeFilter tagFilter = make(QStringLiteral("tag:%1").arg(tag.id), + tag.name, tagCounts.value(tag.id)); + tagFilter.tagId = tag.id; + filters.append(std::move(tagFilter)); + } + } + return filters; +} + +FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, + const QSet& active, + const std::optional& query) { + const bool filtering = query && !query->isEmpty(); + const QString text = + filtering ? query->text.trimmed().toCaseFolded() : QString{}; + const Resolver resolver(state); + + QHash> allByFolder; + QHash> visibleByFolder; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + const ListedMod& mod = *it.value(); + const QString& uuid = it.key(); + const auto pid = modPackageId(mod); + const QString author = modAuthor(mod); + Resolution where = resolver.resolve(pid, mod.name, author); + if (!state.folders.contains(where.folderId)) { + where = Resolution{kRootId, Placement::kNone}; + } + const ModLeaf leaf{ + .uuid = uuid, + .packageId = pid, + .name = mod.name, + .author = author, + .enabled = active.contains(uuid), + .tagIds = pid ? state.modTags.value(*pid) : QSet{}, + .placement = where.placement, + }; + allByFolder[where.folderId].append(leaf); + if (!filtering || matchesQuery(leaf, mod.providerId, *query, text)) { + visibleByFolder[where.folderId].append(leaf); + } + } + + QHash> childrenOf; + for (const Folder& folder : state.folders) { + if (folder.parentId) { + childrenOf[*folder.parentId].append(folder); + } + } + QSet ruled; + for (const Rule& rule : state.rules) { + ruled.insert(rule.folderId); + } + + TreeBuilder builder{childrenOf, allByFolder, visibleByFolder, + ruled, filtering, {}}; + return builder.build(state.folders.value(kRootId), 0); +} + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/resolve.hpp b/src/core/organizer/resolve.hpp new file mode 100644 index 0000000..c28099c --- /dev/null +++ b/src/core/organizer/resolve.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/models/metadata/structures.hpp" +#include "core/organizer/models.hpp" + +namespace px::modrim::organizer { + +// Discovered mods keyed by uuid. +using ModsByUuid = QHash; + +// Where a mod's folder came from: a manual placement, an auto-folder rule, or +// nowhere (root). +enum class Placement { kManual, kRule, kNone }; +enum class CheckState { kOn, kOff, kPartial }; +enum class StatusFilter { kAll, kActive, kInactive }; + +struct Resolution { + qint64 folderId = kRootId; + Placement placement = Placement::kNone; +}; + +struct TreeQuery { + QString text; + QSet tagIds; + StatusFilter status = StatusFilter::kAll; + QSet providerIds; + + bool isEmpty() const; +}; + +// A sidebar preset (status, provider, or tag) and the mods it selects. +struct TreeFilter { + QString key; + QString label; + int count = 0; + StatusFilter status = StatusFilter::kAll; + std::optional providerId; + std::optional tagId; + + TreeQuery query(const QString& text = {}) const; +}; + +struct ModLeaf { + QString uuid; + std::optional packageId; + QString name; + QString author; + bool enabled = false; + QSet tagIds; + Placement placement = Placement::kNone; +}; + +struct FolderNode { + Folder folder; + int depth = 0; + // std::vector: QList cannot hold its own incomplete element type. + std::vector children; + QList mods; + int total = 0; + int enabled = 0; + CheckState check = CheckState::kOff; + int visibleTotal = 0; + int ownTotal = 0; + int ownEnabled = 0; + bool hasRules = false; + + // Check state of the folder's direct mods only (the root's Ungrouped). + CheckState ownCheck() const; + // This node followed by all descendants, in pre-order. + QList walk() const; + QList allMods() const; +}; + +// First-match counts for a draft rule list, and the ungrouped mods it would +// assign. +struct RulePreview { + QList counts; + int assignable = 0; +}; + +// Lowercased package id for About.xml mods; nullopt for mods without one. +std::optional modPackageId(const ListedMod& mod); +QString modAuthor(const ListedMod& mod); + +Resolution folderFor(const OrganizerState& state, + const std::optional& packageId, + const QString& name, const QString& author); + +RulePreview previewRuleMatches(const OrganizerState& state, + const ModsByUuid& mods, + const QList& specs); + +// `state` may be null, in which case no tag filters are produced. +QList treeFilters(const ModsByUuid& mods, + const QSet& active, + const OrganizerState* state); + +FolderNode buildTree(const OrganizerState& state, const ModsByUuid& mods, + const QSet& active, + const std::optional& query = std::nullopt); + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/service.cpp b/src/core/organizer/service.cpp new file mode 100644 index 0000000..9b63898 --- /dev/null +++ b/src/core/organizer/service.cpp @@ -0,0 +1,626 @@ +#include "core/organizer/service.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "core/organizer/defaults.hpp" + +namespace px::modrim::organizer { +namespace { + +const QHash kEmptyMods; +const QStringList kEmptyActive; + +Result cleanName(const QString& name, const QString& what) { + const QString cleaned = name.trimmed(); + if (cleaned.isEmpty()) { + return std::unexpected( + Error{QStringLiteral("%1 name cannot be empty.").arg(what)}); + } + return cleaned; +} + +} // namespace + +OrganizerService::OrganizerService(CoreContext& ctx, OrganizerDb& db) + : Plugin(QStringLiteral("organizer")), m_ctx(&ctx), m_db(db) {} + +OrganizerService::OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, + OrganizerDb& db) + : Plugin(QStringLiteral("organizer")), + m_allModsFn(std::move(allMods)), + m_activeUuidsFn(std::move(activeUuids)), + m_db(db) {} + +const QHash& OrganizerService::allMods() const { + if (m_allModsFn) { + return m_allModsFn(); + } + if (m_ctx) { + return m_ctx->allMods(); + } + return kEmptyMods; +} + +const QStringList& OrganizerService::activeUuids() const { + if (m_activeUuidsFn) { + return m_activeUuidsFn(); + } + if (m_ctx) { + return m_ctx->activeUuids(); + } + return kEmptyActive; +} + +QCoro::Task OrganizerService::init() { + auto loaded = co_await m_db.load(); + if (!loaded) { + // Plugin::init cannot report failure, so the service stays not ready and + // says why. + spdlog::error("Cannot load organizer state: {}", + loaded.error().message.toStdString()); + co_return; + } + m_state = std::move(*loaded); + changed.notify({}); +} + +QCoro::Task OrganizerService::init(CoreContext&) { co_await init(); } + +QCoro::Task OrganizerService::shutdown() { co_await m_db.close(); } + +bool OrganizerService::ready() const noexcept { return m_state.has_value(); } + +const OrganizerState& OrganizerService::state() const { + Q_ASSERT(m_state.has_value()); + return *m_state; +} + +QCoro::Task> OrganizerService::commit() { + auto loaded = co_await m_db.load(); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_state = std::move(*loaded); + changed.notify({}); + co_return {}; +} + +// ── Validation helpers ─────────────────────────────────────────────────────── + +Result OrganizerService::findFolder(qint64 folderId) const { + const auto& folders = state().folders; + const auto it = folders.constFind(folderId); + if (it == folders.cend()) { + return std::unexpected( + Error{QStringLiteral("That folder no longer exists.")}); + } + return it.value(); +} + +Result OrganizerService::findNonRoot(qint64 folderId) const { + if (folderId == kRootId) { + return std::unexpected( + Error{QStringLiteral("The root folder cannot be changed.")}); + } + return findFolder(folderId); +} + +Result OrganizerService::findTag(qint64 tagId) const { + const auto& tags = state().tags; + const auto it = tags.constFind(tagId); + if (it == tags.cend()) { + return std::unexpected(Error{QStringLiteral("That tag no longer exists.")}); + } + return it.value(); +} + +int OrganizerService::depth(qint64 folderId) const { + const auto& folders = state().folders; + int count = 0; + auto current = folders.constFind(folderId); + while (current != folders.cend() && current->parentId && count <= kMaxDepth) { + ++count; + current = folders.constFind(*current->parentId); + } + return count; +} + +QSet OrganizerService::subtreeIds(qint64 folderId) const { + QSet ids{folderId}; + QList pending{folderId}; + while (!pending.isEmpty()) { + const qint64 current = pending.takeLast(); + for (const Folder& child : state().folders) { + if (child.parentId == current && !ids.contains(child.id)) { + ids.insert(child.id); + pending.append(child.id); + } + } + } + return ids; +} + +int OrganizerService::height(qint64 folderId, int limit) const { + if (limit == 1) { + return 1; + } + int tallest = 0; + for (const Folder& child : state().folders) { + if (child.parentId == folderId) { + tallest = std::max(tallest, height(child.id, limit - 1)); + } + } + return 1 + tallest; +} + +Result OrganizerService::checkDepth(qint64 parentId, + int treeHeight) const { + if (depth(parentId) + treeHeight > kMaxDepth) { + return std::unexpected( + Error{QStringLiteral("Folders can be nested at most %1 levels deep.") + .arg(kMaxDepth)}); + } + return {}; +} + +Result OrganizerService::checkUniqueFolderName( + qint64 parentId, const QString& name, std::optional exclude) const { + const QString key = name.toCaseFolded(); + for (const Folder& sibling : state().folders) { + if (sibling.parentId != parentId || sibling.id == exclude) { + continue; + } + if (sibling.name.toCaseFolded() == key) { + return std::unexpected( + Error{QStringLiteral("A folder named \"%1\" already exists here.") + .arg(name)}); + } + } + return {}; +} + +Result OrganizerService::checkTag( + const QString& name, const QString& color, + std::optional exclude) const { + auto cleaned = cleanName(name, QStringLiteral("Tag")); + if (!cleaned) { + return std::unexpected(cleaned.error()); + } + const QString key = cleaned->toCaseFolded(); + for (const Tag& tag : state().tags) { + if (exclude && tag.id == *exclude) { + continue; + } + if (tag.name.toCaseFolded() == key) { + return std::unexpected(Error{ + QStringLiteral("A tag named \"%1\" already exists.").arg(*cleaned)}); + } + } + static const QRegularExpression colorPattern( + QStringLiteral(R"(\A#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\z)")); + if (!colorPattern.match(color).hasMatch()) { + return std::unexpected( + Error{QStringLiteral("\"%1\" is not a valid color (use #RRGGBB).") + .arg(color)}); + } + return *cleaned; +} + +Result OrganizerService::validateNewFolder(const QString& name, + qint64 parentId) const { + auto cleaned = cleanName(name, QStringLiteral("Folder")); + if (!cleaned) { + return std::unexpected(cleaned.error()); + } + if (auto parent = findFolder(parentId); !parent) { + return std::unexpected(parent.error()); + } + if (auto fits = checkDepth(parentId, 1); !fits) { + return std::unexpected(fits.error()); + } + if (auto unique = checkUniqueFolderName(parentId, *cleaned); !unique) { + return std::unexpected(unique.error()); + } + return *cleaned; +} + +QSet OrganizerService::activeSet() const { + const QStringList& active = activeUuids(); + return QSet(active.cbegin(), active.cend()); +} + +// ── Folders ────────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::createFolder(QString name, + qint64 parentId) { + auto cleaned = validateNewFolder(name, parentId); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await m_db.createFolder(parentId, *cleaned); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::createFolderFrom( + QString name, QStringList packageIds, qint64 parentId) { + auto cleaned = validateNewFolder(name, parentId); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await m_db.createFolderWith(parentId, *cleaned, + cleanPackageIds(packageIds)); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::renameFolder(qint64 folderId, + QString name) { + auto existing = findNonRoot(folderId); + if (!existing) { + co_return std::unexpected(existing.error()); + } + auto cleaned = cleanName(name, QStringLiteral("Folder")); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + if (*cleaned == existing->name) { + co_return {}; + } + const qint64 parentId = existing->parentId.value_or(kRootId); + if (auto unique = checkUniqueFolderName(parentId, *cleaned, folderId); + !unique) { + co_return std::unexpected(unique.error()); + } + if (auto renamed = co_await m_db.renameFolder(folderId, *cleaned); !renamed) { + co_return std::unexpected(renamed.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::moveFolder(qint64 folderId, + qint64 parentId) { + auto folder = findNonRoot(folderId); + if (!folder) { + co_return std::unexpected(folder.error()); + } + if (auto target = findFolder(parentId); !target) { + co_return std::unexpected(target.error()); + } + if (folder->parentId == parentId) { + co_return {}; + } + if (subtreeIds(folderId).contains(parentId)) { + co_return std::unexpected( + Error{QStringLiteral("A folder cannot be moved into itself.")}); + } + // The whole subtree moves, so its height counts against the depth limit. + if (auto fits = checkDepth(parentId, height(folderId)); !fits) { + co_return std::unexpected(fits.error()); + } + if (auto unique = checkUniqueFolderName(parentId, folder->name, folderId); + !unique) { + co_return std::unexpected(unique.error()); + } + if (auto moved = co_await m_db.setParent(folderId, parentId); !moved) { + co_return std::unexpected(moved.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::setCollapsed(qint64 folderId, + bool collapsed) { + auto folder = findFolder(folderId); + if (!folder) { + co_return std::unexpected(folder.error()); + } + if (folder->collapsed == collapsed) { + co_return {}; + } + if (auto updated = co_await m_db.setCollapsed(folderId, collapsed); + !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::setAllCollapsed(bool collapsed) { + const bool unchanged = + std::all_of(state().folders.cbegin(), state().folders.cend(), + [collapsed](const Folder& folder) { + return folder.collapsed == collapsed; + }); + if (unchanged) { + co_return {}; + } + if (auto updated = co_await m_db.setAllCollapsed(collapsed); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::deleteFolder(qint64 folderId) { + if (auto folder = findNonRoot(folderId); !folder) { + co_return std::unexpected(folder.error()); + } + if (auto deleted = co_await m_db.deleteFolder(folderId); !deleted) { + co_return std::unexpected(deleted.error()); + } + co_return co_await commit(); +} + +// ── Placement ──────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::place(QStringList packageIds, + qint64 folderId) { + if (auto folder = findFolder(folderId); !folder) { + co_return std::unexpected(folder.error()); + } + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + if (auto placed = co_await m_db.place(pids, folderId); !placed) { + co_return std::unexpected(placed.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::ungroup(QStringList packageIds) { + co_return co_await place(std::move(packageIds), kRootId); +} + +QCoro::Task> OrganizerService::resetToRules( + QStringList packageIds) { + const QStringList pids = cleanPackageIds(packageIds); + if (pids.isEmpty()) { + co_return {}; + } + if (auto unplaced = co_await m_db.unplace(pids); !unplaced) { + co_return std::unexpected(unplaced.error()); + } + co_return co_await commit(); +} + +// ── Tags ───────────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::createTag(QString name, + QString color) { + auto cleaned = checkTag(name, color); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + auto created = co_await m_db.createTag(*cleaned, color); + if (!created) { + co_return std::unexpected(created.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return *created; +} + +QCoro::Task> OrganizerService::updateTag(qint64 tagId, + QString name, + QString color) { + if (auto tag = findTag(tagId); !tag) { + co_return std::unexpected(tag.error()); + } + auto cleaned = checkTag(name, color, tagId); + if (!cleaned) { + co_return std::unexpected(cleaned.error()); + } + if (auto updated = co_await m_db.updateTag(tagId, *cleaned, color); + !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::deleteTag(qint64 tagId) { + if (auto tag = findTag(tagId); !tag) { + co_return std::unexpected(tag.error()); + } + if (auto deleted = co_await m_db.deleteTag(tagId); !deleted) { + co_return std::unexpected(deleted.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::setModTags(QStringList packageIds, + QSet add, + QSet remove) { + const QStringList pids = cleanPackageIds(packageIds); + if (add.intersects(remove)) { + co_return std::unexpected( + Error{QStringLiteral("A tag cannot be added and removed at once.")}); + } + for (qint64 tagId : add | remove) { + if (auto tag = findTag(tagId); !tag) { + co_return std::unexpected(tag.error()); + } + } + if (pids.isEmpty() || (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + if (auto updated = co_await m_db.setModTags(pids, add, remove); !updated) { + co_return std::unexpected(updated.error()); + } + co_return co_await commit(); +} + +// ── Rules ──────────────────────────────────────────────────────────────────── + +QCoro::Task> OrganizerService::setRules(QList specs) { + for (RuleSpec& spec : specs) { + spec.pattern = spec.pattern.trimmed(); + if (spec.pattern.isEmpty()) { + co_return std::unexpected( + Error{QStringLiteral("Rule pattern cannot be empty.")}); + } + if (spec.folderId == kRootId) { + co_return std::unexpected( + Error{QStringLiteral("Rules must target a folder, not the root.")}); + } + if (auto target = findFolder(spec.folderId); !target) { + co_return std::unexpected(target.error()); + } + } + if (auto replaced = co_await m_db.replaceRules(specs); !replaced) { + co_return std::unexpected(replaced.error()); + } + co_return co_await commit(); +} + +QCoro::Task> OrganizerService::addStandardRules() { + // (field, op, casefolded pattern) identifies a rule regardless of folder or + // position. + using RuleKey = std::tuple; + std::set present; + for (const Rule& rule : state().rules) { + present.emplace(static_cast(rule.field), static_cast(rule.op), + rule.pattern.toCaseFolded()); + } + + QSet knownFolders; + for (const Folder& folder : state().folders) { + if (folder.parentId == kRootId) { + knownFolders.insert(folder.name.toCaseFolded()); + } + } + + QList rules; + QStringList newFolders; + for (const StandardRule& rule : standardRules()) { + const RuleKey key{static_cast(rule.field), static_cast(rule.op), + rule.pattern.toCaseFolded()}; + if (present.contains(key)) { + continue; + } + present.insert(key); + rules.append(rule); + // The first spelling of a missing top-level folder names it, as in the + // legacy service. + const QString folderKey = rule.folder.toCaseFolded(); + if (!knownFolders.contains(folderKey)) { + knownFolders.insert(folderKey); + newFolders.append(rule.folder); + } + } + if (rules.isEmpty()) { + co_return 0; + } + if (auto appended = + co_await m_db.appendNamedRules(kRootId, newFolders, rules); + !appended) { + co_return std::unexpected(appended.error()); + } + if (auto committed = co_await commit(); !committed) { + co_return std::unexpected(committed.error()); + } + co_return static_cast(rules.size()); +} + +// ── Read helpers ───────────────────────────────────────────────────────────── + +FolderNode OrganizerService::tree(const std::optional& query) const { + return buildTree(state(), allMods(), activeSet(), query); +} + +Result OrganizerService::folderModUuids(qint64 folderId, + bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + for (const FolderNode* node : root.walk()) { + if (node->folder.id != folderId) { + continue; + } + const QList leaves = recursive ? node->allMods() : node->mods; + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + return uuids; + } + return QStringList{}; +} + +Result OrganizerService::folderToggle(qint64 folderId, + bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + for (const FolderNode* node : root.walk()) { + if (node->folder.id != folderId) { + continue; + } + const QList leaves = recursive ? node->allMods() : node->mods; + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + const CheckState check = recursive ? node->check : node->ownCheck(); + if (check == CheckState::kOn) { + return FolderToggle{QStringList{}, uuids}; + } + return FolderToggle{uuids, QStringList{}}; + } + return FolderToggle{}; +} + +Result> OrganizerService::folderMoveTargets( + qint64 folderId) const { + auto folder = findNonRoot(folderId); + if (!folder) { + return std::unexpected(folder.error()); + } + const QSet subtree = subtreeIds(folderId); + const int treeHeight = height(folderId); + const QString key = folder->name.toCaseFolded(); + + QSet targets; + for (const Folder& candidate : state().folders) { + if (subtree.contains(candidate.id) || candidate.id == folder->parentId) { + continue; + } + if (depth(candidate.id) + treeHeight > kMaxDepth) { + continue; + } + const auto& folders = state().folders; + const bool clash = std::any_of(folders.cbegin(), folders.cend(), + [&candidate, &key](const Folder& child) { + return child.parentId == candidate.id && + child.name.toCaseFolded() == key; + }); + if (!clash) { + targets.insert(candidate.id); + } + } + return targets; +} + +QList OrganizerService::treeFilters() const { + return organizer::treeFilters(allMods(), activeSet(), &state()); +} + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/service.hpp b/src/core/organizer/service.hpp new file mode 100644 index 0000000..58b1e86 --- /dev/null +++ b/src/core/organizer/service.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context_fwd.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/organizer/db.hpp" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" +#include "core/plugin.hpp" + +namespace px::modrim { +class CoreContext; +} + +namespace px::modrim::organizer { + +struct FolderToggle { + QStringList enable; + QStringList disable; +}; + +// Virtual folder tree, tags and auto-folder rules. Never touches load order. +// Reads mods and the active list from the context; each mutation persists, +// reloads the state from the database and then emits `changed`. +class OrganizerService : public Plugin { + public: + using AllModsFn = std::function&()>; + using ActiveUuidsFn = std::function; + + OrganizerService(CoreContext& ctx, OrganizerDb& db); + OrganizerService(AllModsFn allMods, ActiveUuidsFn activeUuids, + OrganizerDb& db); + + Event changed; + + QCoro::Task init(); + QCoro::Task init(CoreContext&) override; + QCoro::Task shutdown() override; + + // Whether init() has loaded state; views built earlier must wait. + bool ready() const noexcept; + // Requires ready(): callers check before reading. + const OrganizerState& state() const; + + // ── Folders ────────────────────────────────────────────────────────────── + QCoro::Task> createFolder(QString name, + qint64 parentId = kRootId); + QCoro::Task> createFolderFrom(QString name, + QStringList packageIds, + qint64 parentId = kRootId); + QCoro::Task> renameFolder(qint64 folderId, QString name); + QCoro::Task> moveFolder(qint64 folderId, qint64 parentId); + QCoro::Task> setCollapsed(qint64 folderId, bool collapsed); + QCoro::Task> setAllCollapsed(bool collapsed); + QCoro::Task> deleteFolder(qint64 folderId); + + // ── Placement ──────────────────────────────────────────────────────────── + QCoro::Task> place(QStringList packageIds, qint64 folderId); + QCoro::Task> ungroup(QStringList packageIds); + QCoro::Task> resetToRules(QStringList packageIds); + + // ── Tags ───────────────────────────────────────────────────────────────── + QCoro::Task> createTag(QString name, QString color); + QCoro::Task> updateTag(qint64 tagId, QString name, + QString color); + QCoro::Task> deleteTag(qint64 tagId); + QCoro::Task> setModTags(QStringList packageIds, QSet add, + QSet remove); + + // ── Rules ──────────────────────────────────────────────────────────────── + QCoro::Task> setRules(QList specs); + // Appends the built-in rules not yet present. Returns how many were added. + QCoro::Task> addStandardRules(); + + // ── Read helpers ───────────────────────────────────────────────────────── + FolderNode tree(const std::optional& query = std::nullopt) const; + // Mods under `folder_id`; `recursive = false` keeps only its direct mods. + Result folderModUuids(qint64 folderId, + bool recursive = true) const; + // (enable, disable) for a folder checkbox click: a fully enabled folder + // disables everything; partial or off enables all. + Result folderToggle(qint64 folderId, + bool recursive = true) const; + // Legal destinations, excluding the folder's current parent. + Result> folderMoveTargets(qint64 folderId) const; + QList treeFilters() const; + + private: + QCoro::Task> commit(); + + Result validateNewFolder(const QString& name, qint64 parentId) const; + Result findFolder(qint64 folderId) const; + Result findNonRoot(qint64 folderId) const; + Result findTag(qint64 tagId) const; + Result checkTag(const QString& name, const QString& color, + std::optional exclude = std::nullopt) const; + Result checkUniqueFolderName( + qint64 parentId, const QString& name, + std::optional exclude = std::nullopt) const; + Result checkDepth(qint64 parentId, int height) const; + int depth(qint64 folderId) const; + int height(qint64 folderId, int limit = kMaxDepth + 1) const; + QSet subtreeIds(qint64 folderId) const; + + const QHash& allMods() const; + const QStringList& activeUuids() const; + QSet activeSet() const; + + CoreContext* m_ctx = nullptr; + AllModsFn m_allModsFn; + ActiveUuidsFn m_activeUuidsFn; + OrganizerDb& m_db; + std::optional m_state; +}; + +} // namespace px::modrim::organizer diff --git a/src/core/plugin.hpp b/src/core/plugin.hpp new file mode 100644 index 0000000..c76d645 --- /dev/null +++ b/src/core/plugin.hpp @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/context_fwd.hpp" +#include "core/error.hpp" +#include "core/toposort.hpp" + +namespace px::modrim { + +// `setup` runs synchronously in dependency order; `init` is awaited right after +// setup, for I/O or async start-up. `shutdown` runs in reverse order. Templated +// over the context type so the UI layer can register plugins against its own +// context. +template +class BasicPlugin { + public: + BasicPlugin(QString name, QStringList dependencies = {}) + : m_name(std::move(name)), m_dependencies(std::move(dependencies)) {} + virtual ~BasicPlugin() = default; + + BasicPlugin(const BasicPlugin&) = delete; + BasicPlugin& operator=(const BasicPlugin&) = delete; + + const QString& name() const noexcept { return m_name; } + const QStringList& dependencies() const noexcept { return m_dependencies; } + + virtual void setup(Ctx&) {} + virtual QCoro::Task init(Ctx&) { co_return; } + virtual QCoro::Task shutdown() { co_return; } + + private: + QString m_name; + QStringList m_dependencies; +}; + +template +class BasicPluginRegistry { + public: + using Plugin = BasicPlugin; + + // Replaces a plugin registered under the same name, keeping its position. + void registerPlugin(std::unique_ptr plugin) { + spdlog::debug("plugin registered: {}", plugin->name().toStdString()); + for (auto& existing : m_plugins) { + if (existing->name() == plugin->name()) { + existing = std::move(plugin); + return; + } + } + m_plugins.push_back(std::move(plugin)); + } + + Plugin* get(const QString& name) const { + for (const auto& plugin : m_plugins) { + if (plugin->name() == name) { + return plugin.get(); + } + } + return nullptr; + } + + QSet names() const { + QSet result; + for (const auto& plugin : m_plugins) { + result.insert(plugin->name()); + } + return result; + } + + // `extra_deps` are dependencies satisfied outside the registry. + Result setupAll(Ctx& ctx, const QSet& extraDeps = {}) { + for (const auto& plugin : m_plugins) { + for (const auto& dep : plugin->dependencies()) { + if (!get(dep) && !extraDeps.contains(dep)) { + return std::unexpected( + Error{QStringLiteral( + "Plugin '%1' depends on '%2' which is not registered") + .arg(plugin->name(), dep)}); + } + } + } + + spdlog::info("setting up {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); + if (!order) { + return std::unexpected(order.error()); + } + for (Plugin* plugin : *order) { + plugin->setup(ctx); + } + spdlog::info("all plugins set up"); + return {}; + } + + QCoro::Task> initAll(Ctx& ctx) { + spdlog::info("initializing {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); + if (!order) { + co_return std::unexpected(order.error()); + } + for (Plugin* plugin : *order) { + co_await plugin->init(ctx); + } + spdlog::info("all plugins initialized"); + co_return {}; + } + + QCoro::Task> shutdownAll() { + spdlog::info("shutting down {} plugins...", m_plugins.size()); + auto order = dependencyOrder(); + if (!order) { + co_return std::unexpected(order.error()); + } + for (auto it = order->rbegin(); it != order->rend(); ++it) { + co_await (*it)->shutdown(); + } + spdlog::info("all plugins shut down"); + co_return {}; + } + + private: + Result> dependencyOrder() const { + std::map> graph; + for (const auto& plugin : m_plugins) { + const auto& deps = plugin->dependencies(); + graph.emplace(plugin->name(), QSet(deps.cbegin(), deps.cend())); + } + + auto levels = toposortLevels(graph); + if (!levels) { + return std::unexpected(levels.error()); + } + std::vector order; + order.reserve(m_plugins.size()); + for (const auto& level : *levels) { + for (const auto& name : level) { + order.push_back(get(name)); + } + } + return order; + } + + std::vector> m_plugins; +}; + +using Plugin = BasicPlugin; +using PluginRegistry = BasicPluginRegistry; + +} // namespace px::modrim diff --git a/src/core/plugin_config.hpp b/src/core/plugin_config.hpp new file mode 100644 index 0000000..b531f30 --- /dev/null +++ b/src/core/plugin_config.hpp @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/events.hpp" + +namespace px::modrim { + +// A plugin's own settings, kept in plugins/.json. `legacy` maps keys of +// the old shared config.json to fields of T; they seed the file the first time +// it is created. Without a service the settings live in memory only. +template + requires std::equality_comparable && std::default_initializable +class PluginConfig { + public: + PluginConfig(ConfigService* service, const QString& name, + const QHash& legacy = {}); + + const T& value() const noexcept { return m_value; } + + void update(T value); + + Event changed; + + private: + T seed(const QHash& legacy) const; + void store(const T& value) const; + + ConfigService* m_service; + QString m_filename; + T m_value{}; +}; + +template + requires std::equality_comparable && std::default_initializable +PluginConfig::PluginConfig(ConfigService* service, const QString& name, + const QHash& legacy) + : m_service(service), + m_filename(QStringLiteral("plugins/%1.json").arg(name)) { + std::optional stored; + if (m_service) { + // plugins/*.json is unversioned, so loading cannot fail with a migration + // error. + auto loaded = m_service->loadExisting(m_filename); + Q_ASSERT(loaded.has_value()); + stored = loaded.value_or(std::nullopt); + } + m_value = stored ? std::move(*stored) : seed(legacy); +} + +template + requires std::equality_comparable && std::default_initializable +T PluginConfig::seed(const QHash& legacy) const { + if (!m_service || legacy.isEmpty()) { + return T{}; + } + const auto raw = m_service->loadRaw("config.json"); + glz::json_t::object_t fields; + for (auto it = legacy.cbegin(); it != legacy.cend(); ++it) { + if (auto found = raw.find(it.key().toStdString()); found != raw.end()) { + fields[it.value().toStdString()] = found->second; + } + } + + const auto text = glz::write_json(fields); + if (!text) { + spdlog::warn("Ignoring old settings for {}: cannot encode legacy values", + m_filename.toStdString()); + return T{}; + } + T value{}; + if (auto error = glz::read(value, *text)) { + spdlog::warn("Ignoring old settings for {}: {}", m_filename.toStdString(), + glz::format_error(error, *text)); + return T{}; + } + if (!fields.empty()) { + store(value); + } + return value; +} + +template + requires std::equality_comparable && std::default_initializable +void PluginConfig::update(T value) { + if (value == m_value) { + return; + } + m_value = std::move(value); + store(m_value); + changed.notify(m_value); +} + +template + requires std::equality_comparable && std::default_initializable +void PluginConfig::store(const T& value) const { + if (!m_service) { + return; + } + if (auto saved = m_service->save(m_filename, value); !saved) { + spdlog::warn("Cannot save {}: {}", m_filename.toStdString(), + saved.error().message.toStdString()); + } +} + +} // namespace px::modrim diff --git a/src/core/profiler.cpp b/src/core/profiler.cpp new file mode 100644 index 0000000..3e7acc0 --- /dev/null +++ b/src/core/profiler.cpp @@ -0,0 +1,60 @@ +#include "core/profiler.hpp" + +#include + +namespace px::modrim { + +namespace { + +void renderNode(const Profiler::Node& node, int depth, QString& out) { + using namespace std::chrono; + const double ms = duration(node.elapsed).count(); + out += QString(depth * 2, QChar(' ')) + node.name + QStringLiteral(": ") + + QString::number(ms, 'f', 3) + QStringLiteral(" ms\n"); + for (const auto& child : node.children) { + renderNode(*child, depth + 1, out); + } +} + +} // namespace + +Profiler::Scope::Scope(Profiler& owner, QAnyStringView name) + : m_owner(owner), + m_parent(owner.m_current), + m_start(std::chrono::steady_clock::now()) { + auto node = std::make_unique(name.toString()); + m_node = node.get(); + m_parent->children.push_back(std::move(node)); + m_owner.m_current = m_node; +} + +Profiler::Scope::~Scope() { + m_node->elapsed = std::chrono::steady_clock::now() - m_start; + m_owner.m_current = m_parent; +} + +void Profiler::adopt(Profiler& child) { + for (auto& node : child.m_root.children) { + m_current->children.push_back(std::move(node)); + } + child.m_root.children.clear(); +} + +QString Profiler::render() const { + QString out; + for (const auto& child : m_root.children) { + renderNode(*child, 0, out); + } + return out; +} + +ProfileSession::ProfileSession(QAnyStringView label) + : m_scope(std::make_unique(m_timer, label)) {} + +ProfileSession::~ProfileSession() { + // Close the top-level scope before rendering so its elapsed time is included. + m_scope.reset(); + spdlog::debug("Profile report:\n{}", m_timer.render().toStdString()); +} + +} // namespace px::modrim diff --git a/src/core/profiler.hpp b/src/core/profiler.hpp new file mode 100644 index 0000000..c61cea9 --- /dev/null +++ b/src/core/profiler.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::modrim { + +// Hierarchical wall-clock timer. Scopes nest according to their lifetimes; +// `render()` prints the resulting tree. +class Profiler { + public: + struct Node { + explicit Node(QString nodeName) : name(std::move(nodeName)) {} + + QString name; + std::chrono::steady_clock::duration elapsed{}; + std::vector> children; + }; + + class Scope { + public: + Scope(Profiler& owner, QAnyStringView name); + ~Scope(); + Scope(const Scope&) = delete; + Scope& operator=(const Scope&) = delete; + Scope(Scope&&) = delete; + Scope& operator=(Scope&&) = delete; + + private: + Profiler& m_owner; + Node* m_node; + Node* m_parent; + std::chrono::steady_clock::time_point m_start; + }; + + Profiler() = default; + + [[nodiscard]] Scope operator()(QAnyStringView name) { + return Scope(*this, name); + } + + // Moves the root timings of `child` under the currently open scope of this + // profiler, so a sub-profile collected elsewhere shows up in this report. + // `child` is left empty. + void adopt(Profiler& child); + + [[nodiscard]] QString render() const; + + private: + Node m_root{QStringLiteral("")}; + Node* m_current = &m_root; +}; + +// Logs the hierarchical report of a block at debug level when it ends. +class ProfileSession { + public: + explicit ProfileSession(QAnyStringView label); + ~ProfileSession(); + ProfileSession(const ProfileSession&) = delete; + ProfileSession& operator=(const ProfileSession&) = delete; + + [[nodiscard]] Profiler& timer() { return m_timer; } + + private: + Profiler m_timer; + std::unique_ptr m_scope; +}; + +} // namespace px::modrim diff --git a/src/core/providers/base.cpp b/src/core/providers/base.cpp new file mode 100644 index 0000000..cd1b97d --- /dev/null +++ b/src/core/providers/base.cpp @@ -0,0 +1,167 @@ +#include "core/providers/base.hpp" + +#include +#include + +#include + +#include "core/models/metadata/parsing.hpp" +#include "core/utils.hpp" + +namespace px::modrim::providers { + +namespace { + +QList statCandidates(const QList& dirs) { + QList candidates; + candidates.reserve(dirs.size()); + for (const ModDirectory& dir : dirs) { + const QFileInfo info(dir.aboutXmlPath); + if (!info.exists()) { + continue; + } + candidates.append(ModCandidate{ + dir.modPath, dir.aboutXmlPath, + info.lastModified().toMSecsSinceEpoch() / 1000.0, info.size()}); + } + return candidates; +} + +Result parseOne(const ModCandidate& candidate, + const QString& targetVersion) { + auto mod = createListedModFromPath(candidate.modPath, targetVersion, true, + candidate.aboutXmlPath); + if (!mod) { + return std::unexpected(mod.error()); + } + return ParsedMod{std::move(*mod), candidate}; +} + +} // namespace + +QList scanModDirectory(const QString& modsPath) { + const QFileInfo root(modsPath); + if (!root.exists() || !root.isDir()) { + spdlog::warn("Mod directory not found: {}", modsPath.toStdString()); + return {}; + } + + QList found; + const QDir dir(modsPath); + const QFileInfoList entries = + dir.entryInfoList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + const QString modPath = dir.filePath(entry.fileName()); + if (const auto aboutXml = findAboutXml(modPath)) { + found.append(ModDirectory{modPath, *aboutXml}); + } + } + return found; +} + +BaseModProvider::BaseModProvider(QString providerId, QString color, + QString path, QThreadPool* pool, + MetadataCacheStore* metadataCache) + : m_providerId(std::move(providerId)), + m_color(std::move(color)), + m_path(std::move(path)), + m_pool(pool), + m_metadataCache(metadataCache) {} + +QCoro::Task> BaseModProvider::loadMods( + QList dirs, QString targetVersion, Profiler& timer, + MetadataCacheStore* metadataCache, bool forceReparse) { + MetadataCacheStore* cache = metadataCache ? metadataCache : m_metadataCache; + + const QList candidates = + co_await runOnPool>( + nullptr, [dirs = std::move(dirs)] { return statCandidates(dirs); }); + + ModMap result; + QList missing; + if (cache && !forceReparse) { + auto scope = timer("cache_lookup"); + CacheLookup lookup = co_await cache->getMods(candidates, targetVersion); + for (ListedModPtr& mod : lookup.cached) { + mod->providerId = m_providerId; + result.insert(mod->uuid(), mod); + } + missing = std::move(lookup.missing); + } else { + missing = candidates; + } + + if (!missing.isEmpty()) { + auto scope = timer("parse_xml"); + Result> parsed = + co_await parseCandidates(missing, targetVersion); + if (!parsed) { + co_return std::unexpected(parsed.error()); + } + if (cache) { + QList cacheItems; + for (const ParsedMod& item : *parsed) { + if (auto about = std::dynamic_pointer_cast(item.mod)) { + cacheItems.append(CachedMod{std::move(about), item.candidate}); + } + } + if (!cacheItems.isEmpty()) { + co_await cache->putMods(std::move(cacheItems), targetVersion); + } + } + for (ParsedMod& item : *parsed) { + item.mod->providerId = m_providerId; + result.insert(item.mod->uuid(), item.mod); + } + } + + // Warm the disk-backed published-file-id cache off the calling thread. + const QList mods = result.values(); + co_await runOnPool(nullptr, [mods] { + for (const ListedModPtr& mod : mods) { + mod->publishedFileId(); + } + }); + co_return result; +} + +QCoro::Task>> BaseModProvider::parseCandidates( + QList missing, QString targetVersion) { + if (m_pool == nullptr || missing.size() <= 1) { + co_return co_await runOnPool>>( + nullptr, [missing = std::move(missing), + targetVersion = std::move(targetVersion)] { + QList parsed; + parsed.reserve(missing.size()); + for (const ModCandidate& candidate : missing) { + Result item = parseOne(candidate, targetVersion); + if (!item) { + return Result>(std::unexpected(item.error())); + } + parsed.append(std::move(*item)); + } + return Result>(std::move(parsed)); + }); + } + + QList>> pending; + pending.reserve(missing.size()); + for (const ModCandidate& candidate : missing) { + pending.append( + runOnPool>(m_pool, [candidate, targetVersion] { + return parseOne(candidate, targetVersion); + })); + } + QList parsed; + parsed.reserve(missing.size()); + for (QFuture>& future : pending) { + Result item = co_await future; + if (!item) { + co_return std::unexpected(item.error()); + } + parsed.append(std::move(*item)); + } + co_return parsed; +} + +} // namespace px::modrim::providers diff --git a/src/core/providers/base.hpp b/src/core/providers/base.hpp new file mode 100644 index 0000000..844e760 --- /dev/null +++ b/src/core/providers/base.hpp @@ -0,0 +1,139 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/profiler.hpp" + +namespace px::modrim::providers { + +// Runs `fn` on `pool`, or on the global pool when `pool` is null, and returns a +// future for its result. Awaiting the future from a coroutine resumes it on the +// calling thread. +template +QFuture runOnPool(QThreadPool* pool, F&& fn) { + auto promise = std::make_shared>(); + QFuture future = promise->future(); + QThreadPool* target = pool ? pool : QThreadPool::globalInstance(); + target->start( + QRunnable::create([promise, fn = std::forward(fn)]() mutable { + promise->start(); + if constexpr (std::is_void_v) { + fn(); + } else { + promise->addResult(fn()); + } + promise->finish(); + })); + return future; +} + +// A directory that holds a mod, with the About.xml that describes it. +struct ModDirectory { + QString modPath; + QString aboutXmlPath; +}; + +// A ModDirectory plus the About.xml stat data that cache entries are validated +// against. +struct ModCandidate { + QString modPath; + QString aboutXmlPath; + double mtime = 0.0; + qint64 size = 0; +}; + +// A freshly parsed mod and the candidate it came from. +struct ParsedMod { + ListedModPtr mod; + ModCandidate candidate; +}; + +// An AboutXmlMod ready to be stored by a metadata cache. +struct CachedMod { + std::shared_ptr mod; + ModCandidate candidate; +}; + +struct CacheLookup { + QList cached; + QList missing; +}; + +// Persistent store of parsed About.xml metadata, keyed by About.xml path and +// validated by its mtime and size. Implemented by the cache service; providers +// only depend on this interface. +class MetadataCacheStore { + public: + MetadataCacheStore() = default; + MetadataCacheStore(const MetadataCacheStore&) = delete; + MetadataCacheStore& operator=(const MetadataCacheStore&) = delete; + virtual ~MetadataCacheStore() = default; + + // Returns the entries that are still valid for `target_version`; everything + // else is missing. + virtual QCoro::Task getMods(QList candidates, + QString targetVersion) = 0; + virtual QCoro::Task putMods(QList entries, + QString targetVersion) = 0; +}; + +// Discovered mods keyed by uuid (the mod path for mods on disk). +using ModMap = QHash; + +// Lists the subdirectories of `modsPath` that contain an About/About.xml. +QList scanModDirectory(const QString& modsPath); + +class BaseModProvider { + public: + // `path` is the root this provider scans. `pool` and `metadata_cache` are + // optional and not owned; they must outlive the provider. + BaseModProvider(QString providerId, QString color, QString path, + QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + BaseModProvider(const BaseModProvider&) = delete; + BaseModProvider& operator=(const BaseModProvider&) = delete; + virtual ~BaseModProvider() = default; + + const QString& providerId() const noexcept { return m_providerId; } + const QString& color() const noexcept { return m_color; } + const QString& path() const noexcept { return m_path; } + + // `metadata_cache` overrides the cache given at construction for this call + // only. + virtual QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) = 0; + + protected: + // Loads metadata for `dirs`, using the cache where entries are still valid. + QCoro::Task> loadMods(QList dirs, + QString targetVersion, Profiler& timer, + MetadataCacheStore* metadataCache, + bool forceReparse); + + private: + QCoro::Task>> parseCandidates( + QList missing, QString targetVersion); + + QString m_providerId; + QString m_color; + QString m_path; + QThreadPool* m_pool; + MetadataCacheStore* m_metadataCache; +}; + +} // namespace px::modrim::providers diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp new file mode 100644 index 0000000..4a057a6 --- /dev/null +++ b/src/core/providers/core.cpp @@ -0,0 +1,42 @@ +#include "core/providers/core.hpp" + +#include +#include + +#include + +namespace px::modrim::providers { + +CoreModProvider::CoreModProvider(QString gamePath, + MetadataCacheStore* metadataCache) + : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), + std::move(gamePath), nullptr, metadataCache) {} + +QCoro::Task> CoreModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + const QString dataDir = QDir(path()).filePath(QStringLiteral("Data")); + spdlog::debug("CoreModProvider scanning: {}", dataDir.toStdString()); + if (!QFileInfo::exists(dataDir)) { + co_return ModMap{}; + } + + QList dirs; + { + auto scope = tm("scan_dir"); + dirs = co_await runOnPool>( + nullptr, [dataDir] { return scanModDirectory(dataDir); }); + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("CoreModProvider discovered {} mods", discovered->size()); + } + co_return discovered; +} + +} // namespace px::modrim::providers diff --git a/src/core/providers/core.hpp b/src/core/providers/core.hpp new file mode 100644 index 0000000..f7e761e --- /dev/null +++ b/src/core/providers/core.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "core/providers/base.hpp" + +namespace px::modrim::providers { + +// Base-game and DLC mods under the game's `Data/` directory. +class CoreModProvider final : public BaseModProvider { + public: + // `gamePath` is the RimWorld install root; `Data/` is resolved inside it. + explicit CoreModProvider(QString gamePath, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; +}; + +} // namespace px::modrim::providers diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp new file mode 100644 index 0000000..4e27de4 --- /dev/null +++ b/src/core/providers/factory.cpp @@ -0,0 +1,26 @@ +#include "core/providers/factory.hpp" + +#include "core/providers/core.hpp" +#include "core/providers/local.hpp" + +namespace px::modrim::providers { + +ProviderList createProviders(const PathConfig& paths, QThreadPool* pool) { + ProviderList providers; + if (!paths.game.empty()) { + providers.push_back( + std::make_unique(QString::fromStdString(paths.game))); + } + if (!paths.local.empty()) { + const QString local = QString::fromStdString(paths.local); + providers.push_back(std::make_unique(local, pool)); + providers.push_back(std::make_unique(local, pool)); + } + if (!paths.workshop.empty()) { + providers.push_back(std::make_unique( + QString::fromStdString(paths.workshop), pool)); + } + return providers; +} + +} // namespace px::modrim::providers diff --git a/src/core/providers/factory.hpp b/src/core/providers/factory.hpp new file mode 100644 index 0000000..c159bab --- /dev/null +++ b/src/core/providers/factory.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +#include "core/config.hpp" +#include "core/providers/base.hpp" + +namespace px::modrim::providers { + +using ProviderList = std::vector>; + +// The default providers for the configured paths: base game, local, downloaded +// and Workshop. Paths that are not set produce no provider. `pool` is not owned +// and must outlive the providers; null uses the global pool. +ProviderList createProviders(const PathConfig& paths, + QThreadPool* pool = nullptr); + +} // namespace px::modrim::providers diff --git a/src/core/providers/labels.hpp b/src/core/providers/labels.hpp new file mode 100644 index 0000000..bcd6122 --- /dev/null +++ b/src/core/providers/labels.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +namespace px::modrim { + +// Display name for a mod provider, mirroring the legacy PROVIDER_LABELS table. +// Unknown ids fall back to the id with underscores as spaces and each word +// title-cased, as Python's str.title() does: a letter starts a new word only +// after an uncased character. +inline QString providerLabel(const QString& providerId) { + if (providerId == QLatin1StringView("local")) { + return QStringLiteral("Local"); + } + if (providerId == QLatin1StringView("steam") || + providerId == QLatin1StringView("downloaded")) { + return QStringLiteral("Steam Workshop"); + } + if (providerId == QLatin1StringView("core")) { + return QStringLiteral("System / Core"); + } + + QString label = providerId; + label.replace(u'_', u' '); + bool afterLetter = false; + for (QChar& c : label) { + if (c.isLetter()) { + c = afterLetter ? c.toLower() : c.toUpper(); + afterLetter = true; + } else { + afterLetter = false; + } + } + return label; +} + +} // namespace px::modrim diff --git a/src/core/providers/local.cpp b/src/core/providers/local.cpp new file mode 100644 index 0000000..4188622 --- /dev/null +++ b/src/core/providers/local.cpp @@ -0,0 +1,109 @@ +#include "core/providers/local.hpp" + +#include +#include + +#include + +namespace px::modrim::providers { + +namespace { + +// Runs off the UI thread: one directory listing plus a published-file-id probe +// per mod. +QList scanWithPublishedIds(const QString& root, + bool hasPublishedFileId) { + QList dirs; + for (const ModDirectory& dir : scanModDirectory(root)) { + const bool hasId = QFileInfo::exists( + QDir(dir.modPath) + .filePath(QStringLiteral("About/PublishedFileId.txt"))); + if (hasId == hasPublishedFileId) { + dirs.append(dir); + } + } + return dirs; +} + +} // namespace + +LocalModProvider::LocalModProvider(QString localPath, QThreadPool* pool, + MetadataCacheStore* metadataCache) + : BaseModProvider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + std::move(localPath), pool, metadataCache) {} + +QCoro::Task> LocalModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + if (!QFileInfo::exists(path())) { + spdlog::debug("LocalModProvider path does not exist: {}", + path().toStdString()); + co_return ModMap{}; + } + spdlog::debug("LocalModProvider scanning: {}", path().toStdString()); + + QList dirs; + { + auto scope = tm("scan_dir"); + dirs = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root, false); }); + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("LocalModProvider discovered {} mods", discovered->size()); + } + co_return discovered; +} + +DownloadedModProvider::DownloadedModProvider(QString localPath, + QThreadPool* pool, + MetadataCacheStore* metadataCache) + : DownloadedModProvider(QStringLiteral("downloaded"), std::move(localPath), + pool, metadataCache) {} + +DownloadedModProvider::DownloadedModProvider(QString providerId, QString path, + QThreadPool* pool, + MetadataCacheStore* metadataCache) + : BaseModProvider(std::move(providerId), QStringLiteral("#3498db"), + std::move(path), pool, metadataCache) {} + +QCoro::Task> DownloadedModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + if (!QFileInfo::exists(path())) { + spdlog::debug("DownloadedModProvider path does not exist: {}", + path().toStdString()); + co_return ModMap{}; + } + spdlog::debug("DownloadedModProvider scanning: {}", path().toStdString()); + + QList dirs; + { + auto scope = tm("scan_dir"); + dirs = co_await runOnPool>( + nullptr, [root = path()] { return scanWithPublishedIds(root, true); }); + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("{} discovered {} mods", providerId().toStdString(), + discovered->size()); + } + co_return discovered; +} + +SteamWorkshopModProvider::SteamWorkshopModProvider( + QString workshopPath, QThreadPool* pool, MetadataCacheStore* metadataCache) + : DownloadedModProvider(QStringLiteral("steam"), std::move(workshopPath), + pool, metadataCache) {} + +} // namespace px::modrim::providers diff --git a/src/core/providers/local.hpp b/src/core/providers/local.hpp new file mode 100644 index 0000000..3012d57 --- /dev/null +++ b/src/core/providers/local.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "core/providers/base.hpp" + +namespace px::modrim::providers { + +// Mods the user keeps in the local mods folder: directories without a published +// file id. +class LocalModProvider final : public BaseModProvider { + public: + LocalModProvider(QString localPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; +}; + +// Mods downloaded by the app into the local folder: directories that carry a +// published file id. +class DownloadedModProvider : public BaseModProvider { + public: + DownloadedModProvider(QString localPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); + + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, + bool forceReparse = false) override; + + protected: + DownloadedModProvider(QString providerId, QString path, QThreadPool* pool, + MetadataCacheStore* metadataCache); +}; + +// Steam Workshop subscriptions: same layout as downloaded mods, under the +// workshop root. +class SteamWorkshopModProvider final : public DownloadedModProvider { + public: + SteamWorkshopModProvider(QString workshopPath, QThreadPool* pool = nullptr, + MetadataCacheStore* metadataCache = nullptr); +}; + +} // namespace px::modrim::providers diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp new file mode 100644 index 0000000..5560f94 --- /dev/null +++ b/src/core/services/activation_service.cpp @@ -0,0 +1,507 @@ +#include "core/services/activation_service.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/checker/graph.hpp" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" + +namespace px::modrim { +namespace { + +using checker::ConstraintEdge; +using checker::ConstraintGraph; +using checker::EdgeType; +using checker::PackageId; +using ModMap = QHash; +using Edge = std::pair; + +// The About.xml mod behind `uuid`, or null when it is unknown or has no +// About.xml. +checker::AboutXmlModPtr aboutMod(const QHash& allMods, + const QString& uuid) { + const auto it = allMods.constFind(uuid); + return it == allMods.cend() + ? nullptr + : std::dynamic_pointer_cast(it.value()); +} + +QStringList enableCandidates(const QHash& allMods, + const QStringList& enable, + const QSet& disableSet, + const QSet& activeSet) { + QStringList candidates; + QSet seen; + for (const QString& uuid : enable) { + if (seen.contains(uuid) || disableSet.contains(uuid)) { + continue; + } + seen.insert(uuid); + if (allMods.contains(uuid) && !activeSet.contains(uuid)) { + candidates.append(uuid); + } + } + return candidates; +} + +// (before, after) pairs between mods touching any of `candidates`. Edges +// through ids outside `mods` (uninstalled optional mods) are dropped so they +// cannot chain unrelated mods together. +QList buildEdges(const ModMap& mods, const QList& order, + const QList& candidates, + const sort::SortSettings& settings, + const sort::CommunityRules& communityRules) { + ConstraintGraph graph; + graph.build(mods, order, settings, &communityRules); + // Placement ignores active-to-active edges, so only edges incident to a + // candidate matter; the graph still resolves the full alternative map. + QSet incident; + for (const PackageId& pid : candidates) { + incident.unite(graph.outgoing(pid)); + incident.unite(graph.incoming(pid)); + } + + QList edges; + for (const ConstraintEdge& edge : incident) { + PackageId before; + PackageId after; + switch (edge.type) { + case EdgeType::kLoadBefore: + before = edge.source; + after = edge.target; + break; + case EdgeType::kDependency: + case EdgeType::kLoadAfter: + before = edge.target; + after = edge.source; + break; + default: + continue; + } + if (before != after && mods.contains(before) && mods.contains(after)) { + edges.append({before, after}); + } + } + return edges; +} + +// Iterative Tarjan SCC. Each component lists its members in `nodes` order. +QList stronglyConnected( + const QStringList& nodes, const QHash>& adjacent) { + struct Frame { + QString node; + QStringList children; + qsizetype next = 0; + }; + + QHash indexOf; + QHash lowlink; + QStringList stack; + QSet onStack; + QList components; + QList work; + + const auto visit = [&](const QString& node) { + const int index = static_cast(indexOf.size()); + indexOf.insert(node, index); + lowlink.insert(node, index); + stack.append(node); + onStack.insert(node); + const QSet successors = adjacent.value(node); + work.append( + Frame{node, QStringList(successors.cbegin(), successors.cend()), 0}); + }; + + for (const QString& root : nodes) { + if (indexOf.contains(root)) { + continue; + } + visit(root); + while (!work.isEmpty()) { + Frame& frame = work.last(); + if (frame.next < frame.children.size()) { + const QString child = frame.children[frame.next++]; + if (!indexOf.contains(child)) { + // Invalidates `frame`; the loop re-reads work.last(). + visit(child); + } else if (onStack.contains(child)) { + lowlink[frame.node] = + std::min(lowlink.value(frame.node), indexOf.value(child)); + } + continue; + } + + // Every child is done: close this node. + const QString node = frame.node; + work.removeLast(); + if (!work.isEmpty()) { + const QString parent = work.last().node; + lowlink[parent] = std::min(lowlink.value(parent), lowlink.value(node)); + } + if (lowlink.value(node) == indexOf.value(node)) { + QStringList component; + QString member; + do { + member = stack.takeLast(); + onStack.remove(member); + component.append(member); + } while (member != node); + components.append(component); + } + } + } + return components; +} + +// Slots enable candidates into gaps of a fixed active order. +// +// Gap i means "just before new_active[i]"; gap new_active.size() is the end. +// Bounds come only from direct edges to active mods, propagated through batch +// candidates, because the active order itself may already violate constraints +// and must not be re-derived transitively. +class Placement { + public: + Placement(QStringList newActive, QStringList candidates, + const QHash& allMods, + const sort::SortSettings& settings, + const sort::CommunityRules& communityRules); + + QStringList assemble() const; + + private: + void addEdge(const PackageId& before, const PackageId& after); + std::pair> orderBatch() const; + QHash propagatedCeilings(const QStringList& ordered, + const QSet& cyclic) const; + int gapFor(const QString& uuid, const QSet& cyclic, + const QHash& ceilings, + const QHash& gaps) const; + + QStringList m_newActive; + QStringList m_candidates; + int m_end; + + QHash m_firstPos; + QHash m_lastPos; + QHash m_uuidsByPid; + + // Batch-internal adjacency plus direct bounds against active mods. + QHash> m_after; + QHash> m_before; + QHash m_floor; + QHash m_ceiling; +}; + +Placement::Placement(QStringList newActive, QStringList candidates, + const QHash& allMods, + const sort::SortSettings& settings, + const sort::CommunityRules& communityRules) + : m_newActive(std::move(newActive)), + m_candidates(std::move(candidates)), + m_end(static_cast(m_newActive.size())) { + ModMap mods; + QList order; + for (qsizetype index = 0; index < m_newActive.size(); ++index) { + const checker::AboutXmlModPtr mod = aboutMod(allMods, m_newActive[index]); + if (!mod) { + continue; + } + const PackageId& pid = mod->packageId; + if (!m_firstPos.contains(pid)) { + m_firstPos.insert(pid, static_cast(index)); + } + m_lastPos.insert(pid, static_cast(index)); + if (!mods.contains(pid)) { + mods.insert(pid, mod); + order.append(pid); + } + } + + for (const QString& uuid : m_candidates) { + m_after.insert(uuid, {}); + m_before.insert(uuid, {}); + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (!mod) { + continue; + } + const PackageId& pid = mod->packageId; + m_uuidsByPid[pid].append(uuid); + if (!mods.contains(pid)) { + mods.insert(pid, mod); + order.append(pid); + } + } + + if (!m_uuidsByPid.isEmpty()) { + for (const Edge& edge : buildEdges(mods, order, m_uuidsByPid.keys(), + settings, communityRules)) { + addEdge(edge.first, edge.second); + } + } +} + +void Placement::addEdge(const PackageId& before, const PackageId& after) { + const QStringList beforeUuids = m_uuidsByPid.value(before); + const QStringList afterUuids = m_uuidsByPid.value(after); + for (const QString& b : beforeUuids) { + for (const QString& a : afterUuids) { + if (a != b) { + m_after[b].insert(a); + m_before[a].insert(b); + } + } + if (const auto first = m_firstPos.constFind(after); + first != m_firstPos.cend()) { + m_ceiling[b] = std::min(m_ceiling.value(b, first.value()), first.value()); + } + } + if (const auto last = m_lastPos.constFind(before); last != m_lastPos.cend()) { + for (const QString& a : afterUuids) { + m_floor[a] = + std::max(m_floor.value(a, last.value() + 1), last.value() + 1); + } + } +} + +QStringList Placement::assemble() const { + const auto [ordered, cyclic] = orderBatch(); + const QHash ceilings = propagatedCeilings(ordered, cyclic); + QList buckets(m_end + 1); + QHash gaps; + for (const QString& uuid : ordered) { + const int gap = gapFor(uuid, cyclic, ceilings, gaps); + gaps.insert(uuid, gap); + buckets[gap].append(uuid); + } + + QStringList result; + for (qsizetype index = 0; index < m_newActive.size(); ++index) { + result.append(buckets[index]); + result.append(m_newActive[index]); + } + result.append(buckets[m_end]); + return result; +} + +int Placement::gapFor(const QString& uuid, const QSet& cyclic, + const QHash& ceilings, + const QHash& gaps) const { + if (cyclic.contains(uuid)) { + return m_end; + } + // Befores of an acyclic candidate sit in earlier components, so their gaps + // are final. + std::optional earliest; + const auto raiseFloor = [&earliest](int floor) { + earliest = earliest ? std::max(*earliest, floor) : floor; + }; + for (const QString& b : m_before.value(uuid)) { + raiseFloor(gaps.value(b)); + } + if (const auto floor = m_floor.constFind(uuid); floor != m_floor.cend()) { + raiseFloor(floor.value()); + } + + std::optional ceiling; + if (const auto found = ceilings.constFind(uuid); found != ceilings.cend()) { + ceiling = found.value(); + } + if (earliest) { + if (ceiling && *earliest > *ceiling) { + return m_end; + } + return *earliest; + } + return ceiling ? *ceiling : m_end; +} + +QHash Placement::propagatedCeilings( + const QStringList& ordered, const QSet& cyclic) const { + // Earliest active position each candidate must precede, via batch chains. + QHash ceilings; + for (auto it = ordered.crbegin(); it != ordered.crend(); ++it) { + if (cyclic.contains(*it)) { + continue; + } + std::optional bound; + const auto tighten = [&bound](int value) { + bound = bound ? std::min(*bound, value) : value; + }; + for (const QString& a : m_after.value(*it)) { + if (const auto found = ceilings.constFind(a); found != ceilings.cend()) { + tighten(found.value()); + } + } + if (const auto own = m_ceiling.constFind(*it); own != m_ceiling.cend()) { + tighten(own.value()); + } + if (bound) { + ceilings.insert(*it, *bound); + } + } + return ceilings; +} + +// Topologically orders candidates by input order; cycle members are placed as a +// unit. +std::pair> Placement::orderBatch() const { + auto components = stronglyConnected(m_candidates, m_after); + + QHash inputIndex; + for (qsizetype index = 0; index < m_candidates.size(); ++index) { + inputIndex.insert(m_candidates[index], static_cast(index)); + } + + QHash componentOf; + QSet cyclic; + for (int index = 0; index < components.size(); ++index) { + QStringList& members = components[index]; + std::sort(members.begin(), members.end(), + [&inputIndex](const QString& a, const QString& b) { + return inputIndex.value(a) < inputIndex.value(b); + }); + for (const QString& member : members) { + componentOf.insert(member, index); + } + if (members.size() > 1) { + cyclic.unite(QSet(members.cbegin(), members.cend())); + } + } + + QList> successors(components.size()); + QList indegree(components.size(), 0); + for (auto it = m_after.cbegin(); it != m_after.cend(); ++it) { + const int source = componentOf.value(it.key()); + for (const QString& successor : it.value()) { + const int target = componentOf.value(successor); + if (target != source && !successors[source].contains(target)) { + successors[source].insert(target); + ++indegree[target]; + } + } + } + + // Ready components pop in input order of their first member. + using Ready = std::pair; + std::priority_queue, std::greater<>> ready; + for (int index = 0; index < components.size(); ++index) { + if (indegree[index] == 0) { + ready.emplace(inputIndex.value(components[index].front()), index); + } + } + QStringList ordered; + while (!ready.empty()) { + const int index = ready.top().second; + ready.pop(); + ordered.append(components[index]); + for (const int target : successors[index]) { + if (--indegree[target] == 0) { + ready.emplace(inputIndex.value(components[target].front()), target); + } + } + } + return {std::move(ordered), std::move(cyclic)}; +} + +} // namespace + +ActivationService::ActivationService(CoreContext& ctx) : m_ctx(ctx) {} + +QStringList ActivationService::dependentsOf(const QStringList& uuids, + const QStringList& enable) const { + const QSet excluded(uuids.cbegin(), uuids.cend()); + const QHash& allMods = m_ctx.allMods(); + const QStringList& active = m_ctx.activeUuids(); + + QSet remaining; + const auto keep = [&](const QString& uuid) { + if (excluded.contains(uuid)) { + return; + } + if (const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid)) { + remaining.insert(mod->packageId); + } + }; + for (const QString& uuid : active) { + keep(uuid); + } + for (const QString& uuid : enable) { + keep(uuid); + } + + QSet roots; + for (const QString& uuid : excluded) { + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (mod && !remaining.contains(mod->packageId)) { + roots.insert(mod->packageId); + } + } + if (roots.isEmpty()) { + return {}; + } + + const ConstraintGraph& graph = m_ctx.diagnosticsService().constraintGraph(); + QSet dependents; + QList pending(roots.cbegin(), roots.cend()); + while (!pending.isEmpty()) { + const PackageId target = pending.takeLast(); + const auto edges = graph.incomingOfType(target, EdgeType::kDependency); + for (const ConstraintEdge& edge : edges) { + if (!roots.contains(edge.source) && !dependents.contains(edge.source)) { + dependents.insert(edge.source); + pending.append(edge.source); + } + } + } + + QStringList result; + for (const QString& uuid : active) { + if (excluded.contains(uuid)) { + continue; + } + const checker::AboutXmlModPtr mod = aboutMod(allMods, uuid); + if (mod && dependents.contains(mod->packageId)) { + result.append(uuid); + } + } + return result; +} + +bool ActivationService::apply(const QStringList& enable, + const QStringList& disable) { + const QStringList& active = m_ctx.activeUuids(); + const QSet disableSet(disable.cbegin(), disable.cend()); + QStringList newActive; + for (const QString& uuid : active) { + if (!disableSet.contains(uuid)) { + newActive.append(uuid); + } + } + const QSet activeSet(active.cbegin(), active.cend()); + QStringList candidates = + enableCandidates(m_ctx.allMods(), enable, disableSet, activeSet); + + const Placement placement(std::move(newActive), std::move(candidates), + m_ctx.allMods(), m_ctx.config().sort, + m_ctx.diagnosticsService().communityRules()); + const QStringList result = placement.assemble(); + if (result == active) { + return false; + } + + m_ctx.setActive(result); + return true; +} + +} // namespace px::modrim diff --git a/src/core/services/activation_service.hpp b/src/core/services/activation_service.hpp new file mode 100644 index 0000000..348242e --- /dev/null +++ b/src/core/services/activation_service.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace px::modrim { + +class CoreContext; + +// Enables and disables mods without reordering the rest of the active list. +class ActivationService { + public: + // `ctx` is not owned and must outlive the service. + explicit ActivationService(CoreContext& ctx); + + // Active mods that depend on packages lost once `uuids` are disabled and + // `enable` is enabled. The inputs themselves are excluded; order follows the + // active list. + QStringList dependentsOf(const QStringList& uuids, + const QStringList& enable = {}) const; + + // Applies a batch of enable/disable changes and returns whether the active + // state changed. A uuid in both lists is disabled. + bool apply(const QStringList& enable = {}, const QStringList& disable = {}); + + private: + CoreContext& m_ctx; +}; + +} // namespace px::modrim diff --git a/src/core/services/diagnostics_service.cpp b/src/core/services/diagnostics_service.cpp new file mode 100644 index 0000000..fd17b95 --- /dev/null +++ b/src/core/services/diagnostics_service.cpp @@ -0,0 +1,366 @@ +#include "core/services/diagnostics_service.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/sort/community.hpp" + +namespace px::modrim { +namespace { + +// Cached databases older than this are refreshed in the background. +constexpr double kDatabaseMaxAgeS = 24 * 3600; + +bool needsRefresh(bool loaded, const std::optional& age) { + return !loaded || !age || *age > kDatabaseMaxAgeS; +} + +std::vector> defaultCheckers() { + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + return checkers; +} + +view::SidebarEntry makeEntry(view::SidebarEntry::Kind kind, QString label, + QSet visibleUuids, + QString providerId = {}) { + view::SidebarEntry entry; + entry.kind = kind; + entry.label = std::move(label); + entry.visibleUuids = std::move(visibleUuids); + entry.providerId = std::move(providerId); + return entry; +} + +} // namespace + +DiagnosticsService::DiagnosticsService(CoreContext& ctx, + QNetworkAccessManager& network) + : m_ctx(ctx), + m_noVersionWarningService(ctx.configService(), network), + m_useThisInsteadService(ctx.configService(), network), + m_checker(defaultCheckers(), ctx.config().sort, ctx.targetVersion(), + [this](const checker::DiagnosticsByUuid& diagnostics) { + onCheckerDiagnosticsChanged(diagnostics); + }) {} + +// ── Lifecycle ────────────────────────────────────────────── + +QCoro::Task> DiagnosticsService::initialize(Profiler* timer) { + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + auto initScope = t("diagnostics.init"); + { + auto scope = t("ensure_databases"); + co_await ensureDatabases(); + } + + std::optional rules; + { + auto scope = t("load_community_rules"); + if (const auto path = communityRulesPath()) { + auto loaded = co_await QtConcurrent::run( + [p = *path] { return sort::loadCommunityRules(p); }); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + rules = std::move(*loaded); + } + } + if (rules && !rules->isEmpty()) { + m_checker.setCommunityRules(std::move(*rules)); + } + co_return {}; +} + +QCoro::Task DiagnosticsService::ensureDatabases() { + // The cache files are read off the UI thread; the service is only touched + // from here. + auto nvw = co_await QtConcurrent::run( + [this] { return m_noVersionWarningService.loadIfExists(); }); + const bool nvwLoaded = !nvw.isEmpty(); + if (nvwLoaded) { + m_checker.setNoVersionWarning(std::move(nvw)); + } + auto uti = co_await QtConcurrent::run( + [this] { return m_useThisInsteadService.loadIfExists(); }); + const bool utiLoaded = !uti.isEmpty(); + if (utiLoaded) { + m_checker.setUseThisInstead(std::move(uti)); + } + + const bool refreshNvw = + needsRefresh(nvwLoaded, m_noVersionWarningService.cacheAgeS()); + const bool refreshUti = + needsRefresh(utiLoaded, m_useThisInsteadService.cacheAgeS()); + if ((refreshNvw || refreshUti) && (!m_dbTask || m_dbTask->isReady())) { + m_dbTask = downloadDatabases(refreshNvw, refreshUti); + } +} + +QCoro::Task DiagnosticsService::downloadDatabases(bool nvw, bool uti) { + backgroundTaskChanged.notify( + QStringLiteral(u"Downloading rule databases\u2026")); + bool changed = false; + if (nvw) { + auto pids = co_await m_noVersionWarningService.ensure(true); + if (!pids.isEmpty()) { + m_checker.setNoVersionWarning(std::move(pids)); + changed = true; + } + } + if (uti) { + auto entries = co_await m_useThisInsteadService.ensure(true); + if (!entries.isEmpty()) { + m_checker.setUseThisInstead(std::move(entries)); + changed = true; + } + } + if (changed) { + rebuild(); + } + backgroundTaskChanged.notify(QString{}); +} + +std::optional DiagnosticsService::communityRulesPath() const { + const AppConfig& config = m_ctx.config(); + if (!config.sort.use_community_rules) { + return std::nullopt; + } + QString path = QString::fromStdString(config.paths.community_rules_file); + if (path.isEmpty()) { + path = QDir(m_ctx.configService().directory()) + .filePath(QStringLiteral("communityRules.json")); + } + if (!QFileInfo::exists(path)) { + return std::nullopt; + } + return path; +} + +// ── Mutations ────────────────────────────────────────────── + +void DiagnosticsService::rebuild(Profiler* timer) { + rebuild(m_ctx.activeUuids(), timer); +} + +void DiagnosticsService::rebuild(const QStringList& activeUuids, + Profiler* timer) { + m_lastActiveUuids = activeUuids; + Profiler local; + Profiler& t = timer != nullptr ? *timer : local; + auto scope = t("diagnostics.rebuild"); + m_checker.rebuild(m_ctx.allMods(), activeUuids, &t); +} + +void DiagnosticsService::reorder(const QStringList& activeUuids) { + m_lastActiveUuids = activeUuids; + m_checker.reorder(activeUuids); +} + +// ── Queries ──────────────────────────────────────────────── + +QHash +DiagnosticsService::activeModsByPid() const { + return m_checker.activeMods(); +} + +const checker::ConstraintGraph& DiagnosticsService::constraintGraph() const { + return m_checker.graph(); +} + +QList DiagnosticsService::activeOrderedPids() const { + return m_checker.orderedPids(); +} + +const sort::CommunityRules& DiagnosticsService::communityRules() const { + return m_checker.communityRules(); +} + +QList DiagnosticsService::issuesFor( + const QString& uuid) const { + const checker::ModDiagnostics* diag = m_checker.diagnosticsFor(uuid); + return diag != nullptr ? toIssueViews(*diag) : QList{}; +} + +std::optional DiagnosticsService::summaryFor( + const QString& uuid) const { + const auto it = m_lastSummary.constFind(uuid); + if (it == m_lastSummary.cend()) { + return std::nullopt; + } + return it.value(); +} + +int DiagnosticsService::activeErrorCount() const { + const auto diagnostics = m_checker.activeModDiagnostics(); + int count = 0; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + count += diag.hasErrors() ? 1 : 0; + } + return count; +} + +// ── Conversions ──────────────────────────────────────────── + +QList DiagnosticsService::toIssueViews( + const checker::ModDiagnostics& diag) { + QList views; + const auto append = [&views](const QList& issues, + bool isError) { + for (const checker::ModIssue& issue : issues) { + view::ModIssueView view; + view.category = issue.category; + view.categoryDisplayName = issue.categoryDisplayName.isEmpty() + ? issue.category + : issue.categoryDisplayName; + if (!issue.detailMessage.isEmpty()) { + view.detail = issue.detailMessage; + } + view.isError = isError; + views.append(std::move(view)); + } + }; + append(diag.errors, true); + append(diag.warnings, false); + return views; +} + +view::ModDiagnosticsView DiagnosticsService::toView( + const checker::ModDiagnostics& diag) { + view::ModDiagnosticsView view; + view.hasErrors = diag.hasErrors(); + view.hasWarnings = diag.hasWarnings(); + view.errorTooltip = diag.errorTooltip(); + view.warningTooltip = diag.warningTooltip(); + return view; +} + +// ── Checker callback ─────────────────────────────────────── + +void DiagnosticsService::onCheckerDiagnosticsChanged( + const checker::DiagnosticsByUuid& diagnostics) { + QHash summary; + summary.reserve(diagnostics.size()); + for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { + summary.insert(it.key(), toView(it.value())); + } + m_lastSummary = std::move(summary); + // Sidebar first: the summary handler re-applies the current sidebar filter + // and must see the entries for this state, not the previous one. + sidebarEntriesChanged.notify(buildSidebarEntries()); + diagnosticsSummaryChanged.notify(m_lastSummary); + statusMessageChanged.notify(formatStatus()); +} + +// ── Status ───────────────────────────────────────────────── + +QString DiagnosticsService::formatStatus() const { + const auto diagnostics = m_checker.activeModDiagnostics(); + int errCount = 0; + int warnCount = 0; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + errCount += diag.hasErrors() ? 1 : 0; + warnCount += diag.hasWarnings() ? 1 : 0; + } + + QStringList parts{QString::number(m_lastActiveUuids.size()) + + QStringLiteral(" active")}; + if (errCount > 0) { + parts.append(QString::number(errCount) + QStringLiteral(" with errors")); + } + if (warnCount > 0) { + parts.append(QString::number(warnCount) + QStringLiteral(" with warnings")); + } + return parts.join(QStringLiteral(" | ")); +} + +// ── Sidebar ──────────────────────────────────────────────── + +QList DiagnosticsService::buildSidebarEntries() { + const QHash& mods = m_ctx.allMods(); + if (!m_sidebarBase || !m_sidebarBase->matches(mods)) { + m_sidebarBase.emplace(mods); + } + const SidebarBase& base = *m_sidebarBase; + + QSet errors = base.invalid; + QSet warnings; + const auto diagnostics = m_checker.activeModDiagnostics(); + for (auto it = diagnostics.cbegin(); it != diagnostics.cend(); ++it) { + if (!mods.contains(it.key())) { + continue; + } + if (it.value().hasErrors()) { + errors.insert(it.key()); + } + if (it.value().hasWarnings()) { + warnings.insert(it.key()); + } + } + + const QSet active(m_lastActiveUuids.cbegin(), + m_lastActiveUuids.cend()); + QSet inactive = base.allUuids; + inactive.subtract(active); + + using Kind = view::SidebarEntry::Kind; + QList entries{ + makeEntry(Kind::kAll, QStringLiteral("All"), base.allUuids), + makeEntry(Kind::kActive, QStringLiteral("Active"), active), + makeEntry(Kind::kInactive, QStringLiteral("Inactive"), + std::move(inactive)), + makeEntry(Kind::kWithErrors, QStringLiteral("With errors"), + std::move(errors)), + makeEntry(Kind::kWithWarnings, QStringLiteral("With warnings"), + std::move(warnings)), + }; + for (auto it = base.byProvider.cbegin(); it != base.byProvider.cend(); ++it) { + entries.append(makeEntry(Kind::kProvider, view::providerLabel(it.key()), + it.value(), it.key())); + } + return entries; +} + +DiagnosticsService::SidebarBase::SidebarBase( + const QHash& current) + : mods(current) { + for (auto it = current.cbegin(); it != current.cend(); ++it) { + allUuids.insert(it.key()); + byProvider[it.value()->providerId].insert(it.key()); + if (!it.value()->valid) { + invalid.insert(it.key()); + } + } +} + +// Compares by mod identity: the same uuids mapping to the same objects. +bool DiagnosticsService::SidebarBase::matches( + const QHash& current) const { + if (mods.size() != current.size()) { + return false; + } + for (auto it = current.cbegin(); it != current.cend(); ++it) { + const auto cached = mods.constFind(it.key()); + if (cached == mods.cend() || cached.value() != it.value()) { + return false; + } + } + return true; +} + +} // namespace px::modrim diff --git a/src/core/services/diagnostics_service.hpp b/src/core/services/diagnostics_service.hpp new file mode 100644 index 0000000..1aad84a --- /dev/null +++ b/src/core/services/diagnostics_service.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/checker/checker.hpp" +#include "core/checker/databases.hpp" +#include "core/checker/graph.hpp" +#include "core/checker/models.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/models/view/diagnostics.hpp" +#include "core/models/view/sidebar.hpp" +#include "core/profiler.hpp" +#include "core/sort/models.hpp" + +class QNetworkAccessManager; + +namespace px::modrim { + +class CoreContext; + +// Keeps the checker in step with the active mods and the rule databases, and +// publishes the diagnostics summary, the sidebar filters and the status line +// derived from it. +class DiagnosticsService { + public: + // `network` is not owned and must outlive the service. + DiagnosticsService(CoreContext& ctx, QNetworkAccessManager& network); + + Event> diagnosticsSummaryChanged; + Event statusMessageChanged; + Event backgroundTaskChanged; + Event> sidebarEntriesChanged; + + // ── Lifecycle ───────────────────────────────────────────── + + // Applies cached databases and community rules, and starts a background + // refresh of stale databases. Fails when the community rules file is + // malformed. + QCoro::Task> initialize(Profiler* timer = nullptr); + + // ── Mutations ───────────────────────────────────────────── + + // Rebuilds the checker for the context's active mods, or for `active_uuids`. + void rebuild(Profiler* timer = nullptr); + void rebuild(const QStringList& activeUuids, Profiler* timer = nullptr); + + // Notifies the checker of a new active mod order. + void reorder(const QStringList& activeUuids); + + // ── Queries ─────────────────────────────────────────────── + + QHash activeModsByPid() const; + const checker::ConstraintGraph& constraintGraph() const; + QList activeOrderedPids() const; + const sort::CommunityRules& communityRules() const; + + // Views of every issue on a mod; empty when the mod is not active. + QList issuesFor(const QString& uuid) const; + std::optional summaryFor(const QString& uuid) const; + int activeErrorCount() const; + + private: + // Partitions that do not depend on diagnostics; reused until the mod list + // changes. + struct SidebarBase { + QHash mods; + QSet allUuids; + QSet invalid; + QMap> byProvider; + + explicit SidebarBase(const QHash& current); + bool matches(const QHash& current) const; + }; + + QCoro::Task ensureDatabases(); + QCoro::Task downloadDatabases(bool nvw, bool uti); + // The community rules file to load, or nullopt when rules are disabled or no + // file exists. + std::optional communityRulesPath() const; + + static QList toIssueViews( + const checker::ModDiagnostics& diag); + static view::ModDiagnosticsView toView(const checker::ModDiagnostics& diag); + + void onCheckerDiagnosticsChanged( + const checker::DiagnosticsByUuid& diagnostics); + QString formatStatus() const; + QList buildSidebarEntries(); + + CoreContext& m_ctx; + checker::NoVersionWarningService m_noVersionWarningService; + checker::UseThisInsteadService m_useThisInsteadService; + checker::ModChecker m_checker; + std::optional> m_dbTask; + QStringList m_lastActiveUuids; + QHash m_lastSummary; + std::optional m_sidebarBase; +}; + +} // namespace px::modrim diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp new file mode 100644 index 0000000..7f556cf --- /dev/null +++ b/src/core/services/game_launcher.cpp @@ -0,0 +1,140 @@ +#include "core/services/game_launcher.hpp" + +#include +#include +#include +#include + +#include + +#include + +#include "core/context.hpp" +#include "core/providers/base.hpp" + +#if defined(Q_OS_UNIX) +#include +#endif + +namespace px::modrim::services { + +namespace { + +Result directArgv(const QString& exe, const AppConfig& cfg, + QHash& env) { + const QString args = QString::fromStdString(cfg.launch_args); + if (currentPlatform() == Platform::kMacOs) { + auto split = splitArguments(args); + if (!split) { + return std::unexpected(split.error()); + } + return QStringList{QStringLiteral("open"), exe, QStringLiteral("--args")} + + *split; + } + auto command = + buildDirectCommand(exe, QString::fromStdString(cfg.launch_wrapper), args); + if (!command) { + return std::unexpected(command.error()); + } + env = std::move(command->env); + return std::move(command->argv); +} + +} // namespace + +GameLauncher::GameLauncher(CoreContext& ctx, ProcessLister lister, + MonitorTiming timing) + : m_ctx(ctx), m_lister(lister), m_monitor(std::move(lister), timing) {} + +QCoro::Task> GameLauncher::launch(LaunchStrategy strategy) { + if (m_ctx.config().paths.game.empty()) { + spdlog::warn("Launch aborted — game path not configured"); + co_return std::unexpected( + Error{QStringLiteral("Game path not configured")}); + } + + spdlog::info("Launching game with strategy: {}", + strategy == LaunchStrategy::kDirect ? "Direct" : "Steam"); + co_await m_monitor.captureBaseline(); + if (strategy == LaunchStrategy::kDirect) { + co_return co_await launchDirect(); + } + co_return co_await launchSteam(); +} + +bool GameLauncher::isRunning() const { return isGameRunning(m_lister); } + +QCoro::Task GameLauncher::waitForStart( + std::chrono::milliseconds timeout) { + return m_monitor.waitForStart(timeout); +} + +QCoro::Task> GameLauncher::waitForExit() { + return m_monitor.waitForExit(); +} + +QCoro::Task> GameLauncher::launchDirect() { + const QString game = QString::fromStdString(m_ctx.config().paths.game); + const auto exe = findExecutable(game); + if (!exe) { + spdlog::warn("Direct launch failed — executable not found in {}", + game.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Game executable not found")}); + } + + ensureSteamAppid(game); + + QHash env; + auto argv = directArgv(*exe, m_ctx.config(), env); + if (!argv) { + spdlog::warn("Invalid launch arguments: {}", + argv.error().message.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Invalid launch arguments: %1") + .arg(argv.error().message)}); + } + + spdlog::info("Spawning process: {} (cwd: {})", + argv->join(QLatin1Char(' ')).toStdString(), game.toStdString()); + auto proc = std::make_unique(); + proc->setProgram(argv->front()); + proc->setArguments(argv->mid(1)); + proc->setWorkingDirectory(game); + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + for (auto it = env.cbegin(); it != env.cend(); ++it) { + environment.insert(it.key(), it.value()); + } + proc->setProcessEnvironment(environment); + proc->setStandardOutputFile(QProcess::nullDevice()); + proc->setStandardErrorFile(QProcess::nullDevice()); +#if defined(Q_OS_UNIX) + // The game gets its own session, as it did under start_new_session=True. + proc->setChildProcessModifier([] { ::setsid(); }); +#endif + proc->start(); + if (!proc->waitForStarted()) { + const QString reason = proc->errorString(); + spdlog::warn("Direct launch failed: {}", reason.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Failed to launch: %1").arg(reason)}); + } + + m_monitor.attachProcess(std::move(proc)); + co_return QStringLiteral("Game launched"); +} + +QCoro::Task> GameLauncher::launchSteam() { + const QString args = QString::fromStdString(m_ctx.config().launch_args); + auto opened = co_await providers::runOnPool>( + nullptr, [args] { return openSteamUrl(args); }); + if (!opened) { + spdlog::warn("Steam launch failed: {}", + opened.error().message.toStdString()); + co_return std::unexpected(Error{ + QStringLiteral("Steam launch failed: %1").arg(opened.error().message)}); + } + co_return QStringLiteral("Launching via Steam..."); +} + +} // namespace px::modrim::services diff --git a/src/core/services/game_launcher.hpp b/src/core/services/game_launcher.hpp new file mode 100644 index 0000000..fb900b3 --- /dev/null +++ b/src/core/services/game_launcher.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include + +#include "core/constants.hpp" +#include "core/error.hpp" +#include "core/services/game_process.hpp" + +namespace px::modrim { +class CoreContext; +} + +namespace px::modrim::services { + +// Starts RimWorld directly or through Steam, and tracks the game until it +// exits. +class GameLauncher { + public: + // `ctx` is not owned and must outlive the launcher. + GameLauncher(CoreContext& ctx, ProcessLister lister = hostProcessLister, + MonitorTiming timing = {}); + + // Resolves to a status message when the launch was started, or to the reason + // it was not. + QCoro::Task> launch(LaunchStrategy strategy); + + // Whether a game process is running right now. Blocks while listing + // processes. + bool isRunning() const; + + QCoro::Task waitForStart( + std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + + // Resolves to the spawned process's exit code, or nullopt when it was not + // spawned here or has not exited. + QCoro::Task> waitForExit(); + + private: + QCoro::Task> launchDirect(); + QCoro::Task> launchSteam(); + + CoreContext& m_ctx; + ProcessLister m_lister; + GameProcessMonitor m_monitor; +}; + +} // namespace px::modrim::services diff --git a/src/core/services/game_process.cpp b/src/core/services/game_process.cpp new file mode 100644 index 0000000..84e24fc --- /dev/null +++ b/src/core/services/game_process.cpp @@ -0,0 +1,484 @@ +#include "core/services/game_process.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "core/constants.hpp" +#include "core/file_io.hpp" +#include "core/providers/base.hpp" + +#if defined(Q_OS_LINUX) +#include +#elif defined(Q_OS_WIN) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +// clang-format off +// tlhelp32.h needs the types windows.h declares, so the order matters. +#include +#include +// clang-format on +#elif defined(Q_OS_MACOS) +#include +#include + +#include +#include +#endif + +namespace px::modrim::services { + +namespace { + +const QSet kProcessNames = { + QStringLiteral("RimWorld"), + QStringLiteral("RimWorldLinux"), + QStringLiteral("RimWorldWin64.exe"), + QStringLiteral("RimWorldWin.exe"), + QStringLiteral("RimWorld.exe"), + QStringLiteral("RimWorld by Ludeon Studios"), +}; + +// Simple POSIX shlex-style tokenizer. Handles single/double quotes and +// backslash escapes. +Result splitPosix(const QString& text) { + QStringList tokens; + QString current; + enum State { kSpace, kWord, kSingleQuote, kDoubleQuote } state = kSpace; + bool escaped = false; + + for (const QChar ch : text) { + if (escaped) { + if (state == kDoubleQuote && ch != u'"' && ch != u'\\' && ch != u'$' && + ch != u'`') { + current.append(u'\\'); + } + current.append(ch); + escaped = false; + continue; + } + + switch (state) { + case kSpace: + if (ch.isSpace()) { + continue; + } + if (ch == u'\\') { + escaped = true; + state = kWord; + } else if (ch == u'\'') { + state = kSingleQuote; + } else if (ch == u'"') { + state = kDoubleQuote; + } else { + current.append(ch); + state = kWord; + } + break; + + case kWord: + if (ch.isSpace()) { + tokens.append(current); + current.clear(); + state = kSpace; + } else if (ch == u'\\') { + escaped = true; + } else if (ch == u'\'') { + state = kSingleQuote; + } else if (ch == u'"') { + state = kDoubleQuote; + } else { + current.append(ch); + } + break; + + case kSingleQuote: + if (ch == u'\'') { + state = kWord; + } else { + current.append(ch); + } + break; + + case kDoubleQuote: + if (ch == u'\\') { + escaped = true; + } else if (ch == u'"') { + state = kWord; + } else { + current.append(ch); + } + break; + } + } + + if (escaped || state == kSingleQuote || state == kDoubleQuote) { + return std::unexpected(Error{QStringLiteral("No closing quotation")}); + } + if (state == kWord || !current.isEmpty()) { + tokens.append(current); + } + return tokens; +} + +// Python's shlex.split(posix=False), which the Windows launch path uses: quotes +// are kept in the token and there are no escapes, so whitespace inside quotes +// does not split. +Result splitWindows(const QString& text) { + QStringList tokens; + QString current; + bool inToken = false; + QChar quote; + for (const QChar ch : text) { + if (!quote.isNull()) { + current.append(ch); + if (ch == quote) { + quote = QChar(); + } + continue; + } + if (ch == u'"' || ch == u'\'') { + quote = ch; + current.append(ch); + inToken = true; + } else if (ch.isSpace()) { + if (inToken) { + tokens.append(current); + current.clear(); + inToken = false; + } + } else { + current.append(ch); + inToken = true; + } + } + if (!quote.isNull()) { + return std::unexpected(Error{QStringLiteral("No closing quotation")}); + } + if (inToken) { + tokens.append(current); + } + return tokens; +} + +bool isEnvAssignment(const QString& token) { + static const QRegularExpression pattern( + QStringLiteral("^[A-Za-z_][A-Za-z0-9_]*=")); + return pattern.match(token).hasMatch(); +} + +} // namespace + +Platform currentPlatform() { +#if defined(Q_OS_MACOS) + return Platform::kMacOs; +#elif defined(Q_OS_WIN) + return Platform::kWindows; +#else + return Platform::kLinux; +#endif +} + +Result splitArguments(const QString& text, Platform platform) { + return platform == Platform::kWindows ? splitWindows(text) : splitPosix(text); +} + +Result buildDirectCommand(const QString& exe, + const QString& wrapper, + const QString& args, + Platform platform) { + auto splitArgs = splitArguments(args, platform); + if (!splitArgs) { + return std::unexpected(splitArgs.error()); + } + auto splitWrapper = splitArguments(wrapper, platform); + if (!splitWrapper) { + return std::unexpected(splitWrapper.error()); + } + + QStringList game; + game.append(exe); + game.append(*splitArgs); + + QStringList tokens = *splitWrapper; + DirectCommand result; + while (!tokens.isEmpty() && isEnvAssignment(tokens.front())) { + const QString token = tokens.takeFirst(); + const qsizetype eq = token.indexOf(u'='); + result.env.insert(token.left(eq), token.mid(eq + 1)); + } + + const qsizetype placeholder = tokens.indexOf(QStringLiteral("%command%")); + if (placeholder >= 0) { + result.argv = tokens.mid(0, placeholder); + result.argv.append(game); + result.argv.append(tokens.mid(placeholder + 1)); + } else { + result.argv = tokens; + result.argv.append(game); + } + return result; +} + +std::optional findExecutable(const QString& gamePath, + Platform platform) { + const QDir dir(gamePath); + if (platform == Platform::kLinux) { + for (const char* name : {"RimWorld", "RimWorldLinux"}) { + const QString candidate = dir.filePath(QString::fromLatin1(name)); + const QFileInfo info(candidate); + if (info.isFile() && info.isExecutable()) { + return candidate; + } + } + return std::nullopt; + } + if (platform == Platform::kMacOs) { + const QString primary = dir.filePath(QStringLiteral("RimWorld.app")); + if (QFileInfo(primary).isDir()) { + return primary; + } + QStringList apps = + dir.entryList(QStringList{QStringLiteral("*.app")}, QDir::Dirs); + apps.sort(); + if (!apps.isEmpty()) { + return dir.filePath(apps.first()); + } + return std::nullopt; + } + for (const char* name : + {"RimWorld.exe", "RimWorldWin64.exe", "RimWorldWin.exe"}) { + const QString candidate = dir.filePath(QString::fromLatin1(name)); + if (QFileInfo(candidate).isFile()) { + return candidate; + } + } + return std::nullopt; +} + +void ensureSteamAppid(const QString& gamePath, Platform platform) { + const QString targetDir = platform == Platform::kMacOs + ? QFileInfo(gamePath).dir().absolutePath() + : gamePath; + const QString path = + QDir(targetDir).filePath(QStringLiteral("steam_appid.txt")); + if (QFileInfo::exists(path)) { + return; + } + const std::string_view appId(kRimworldSteamAppId.data(), + kRimworldSteamAppId.size()); + if (auto written = writeFileAtomically(path, appId); !written) { + spdlog::warn("Failed to create steam_appid.txt: {}", + written.error().message.toStdString()); + } else { + spdlog::debug("Created {}", path.toStdString()); + } +} + +QString steamRunUrl(const QString& launchArgs) { + const QString trimmed = launchArgs.trimmed(); + if (trimmed.isEmpty()) { + return QStringLiteral("steam://rungameid/%1") + .arg(QString(kRimworldSteamAppId)); + } + const QByteArray encoded = QUrl::toPercentEncoding(trimmed); + return QStringLiteral("steam://run/%1//%2/") + .arg(QString(kRimworldSteamAppId), QString::fromUtf8(encoded)); +} + +Result openSteamUrl(const QString& launchArgs) { + const QString url = steamRunUrl(launchArgs); + spdlog::info("Opening Steam URL: {}", url.toStdString()); +#if defined(Q_OS_WIN) + const bool started = QProcess::startDetached( + QStringLiteral("cmd"), + {QStringLiteral("/c"), QStringLiteral("start"), QString(), url}); +#elif defined(Q_OS_MACOS) + const bool started = QProcess::startDetached(QStringLiteral("open"), {url}); +#else + const bool started = + QProcess::startDetached(QStringLiteral("xdg-open"), {url}); +#endif + if (!started) { + return std::unexpected( + Error{QStringLiteral("could not start the system URL opener")}); + } + return {}; +} + +std::vector hostProcessLister() { + std::vector procs; +#if defined(Q_OS_LINUX) + DIR* proc = opendir("/proc"); + if (!proc) { + return procs; + } + while (struct dirent* entry = readdir(proc)) { + if (entry->d_type != DT_DIR) { + continue; + } + bool ok = false; + const qint64 pid = QString::fromLatin1(entry->d_name).toLongLong(&ok); + if (!ok || pid <= 0) { + continue; + } + QFile file(QStringLiteral("/proc/%1/stat").arg(pid)); + if (!file.open(QIODevice::ReadOnly)) { + continue; + } + const QByteArray stat = file.readAll(); + const qsizetype openParen = stat.indexOf('('); + const qsizetype closeParen = stat.lastIndexOf(')'); + if (openParen < 0 || closeParen <= openParen) { + continue; + } + const QString comm = + QString::fromUtf8(stat.mid(openParen + 1, closeParen - openParen - 1)); + const QByteArray rest = stat.mid(closeParen + 2); + const bool zombie = !rest.isEmpty() && rest.at(0) == 'Z'; + procs.push_back(ProcessInfo{pid, comm, zombie}); + } + closedir(proc); +#elif defined(Q_OS_WIN) + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return procs; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + for (BOOL more = Process32FirstW(snapshot, &entry); more; + more = Process32NextW(snapshot, &entry)) { + // The toolhelp snapshot does not expose process state, so zombies are never + // seen. + procs.push_back(ProcessInfo{static_cast(entry.th32ProcessID), + QString::fromWCharArray(entry.szExeFile), + false}); + } + CloseHandle(snapshot); +#elif defined(Q_OS_MACOS) + // proc_listallpids takes a buffer size in bytes and returns the number of + // pids written. + std::vector pids( + static_cast(proc_listallpids(nullptr, 0)) + 64); + const int written = proc_listallpids( + pids.data(), static_cast(pids.size() * sizeof(pid_t))); + // SZOMBIE from , which is not usable from user code. + constexpr std::uint32_t kZombieStatus = 5; + for (int i = 0; i < written; ++i) { + const pid_t pid = pids[static_cast(i)]; + char name[256] = {}; + if (proc_name(pid, name, sizeof(name)) <= 0) { + continue; + } + proc_bsdinfo info{}; + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != + static_cast(sizeof(info))) { + continue; + } + procs.push_back(ProcessInfo{pid, QString::fromUtf8(name), + info.pbi_status == kZombieStatus}); + } +#endif + return procs; +} + +QSet liveGamePids(const ProcessLister& lister) { + QSet pids; + for (const ProcessInfo& proc : lister()) { + if (!proc.zombie && kProcessNames.contains(proc.name)) { + pids.insert(proc.pid); + } + } + return pids; +} + +bool isGameRunning(const ProcessLister& lister) { + return !liveGamePids(lister).isEmpty(); +} + +GameProcessMonitor::GameProcessMonitor(ProcessLister lister, + MonitorTiming timing) + : m_lister(std::move(lister)), m_timing(timing) {} + +GameProcessMonitor::~GameProcessMonitor() { dropProcess(); } + +QCoro::Task GameProcessMonitor::captureBaseline() { + m_baseline = co_await providers::runOnPool>( + nullptr, [this] { return liveGamePids(m_lister); }); +} + +void GameProcessMonitor::attachProcess(std::unique_ptr proc) { + dropProcess(); + m_proc = std::move(proc); +} + +bool GameProcessMonitor::launchedAlive() const { + const QSet live = liveGamePids(m_lister); + return std::ranges::any_of( + live, [this](qint64 pid) { return !m_baseline.contains(pid); }); +} + +void GameProcessMonitor::dropProcess() { + if (m_proc && m_proc->state() != QProcess::NotRunning) { + // Intentionally leaked: the child keeps running after the monitor is gone, + // as it did under Popen, and destroying a running QProcess would kill it. + static_cast(m_proc.release()); + return; + } + m_proc.reset(); +} + +QCoro::Task GameProcessMonitor::waitForStart( + std::chrono::milliseconds timeout) { + const auto deadline = std::chrono::steady_clock::now() + timeout; + while (std::chrono::steady_clock::now() < deadline) { + const bool alive = co_await providers::runOnPool( + nullptr, [this] { return launchedAlive(); }); + if (alive) { + co_return true; + } + if (m_proc && m_proc->state() == QProcess::NotRunning) { + co_return false; + } + if (m_timing.pollStart.count() > 0) { + co_await QCoro::sleepFor(m_timing.pollStart); + } + } + co_return false; +} + +QCoro::Task> GameProcessMonitor::waitForExit() { + while (co_await providers::runOnPool( + nullptr, [this] { return launchedAlive(); })) { + if (m_timing.pollExit.count() > 0) { + co_await QCoro::sleepFor(m_timing.pollExit); + } + } + if (!m_proc) { + co_return std::nullopt; + } + std::optional code; + if (m_proc->state() == QProcess::NotRunning) { + code = m_proc->exitCode(); + } + dropProcess(); + co_return code; +} + +} // namespace px::modrim::services diff --git a/src/core/services/game_process.hpp b/src/core/services/game_process.hpp new file mode 100644 index 0000000..2e861f5 --- /dev/null +++ b/src/core/services/game_process.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/error.hpp" + +namespace px::modrim::services { + +enum class Platform { + kLinux, + kWindows, + kMacOs, +}; + +// Returns the host's platform. +Platform currentPlatform(); + +// Splits a command line the way the platform's launcher does: POSIX shell +// quoting, or the Windows command-line rules. Fails on unbalanced quotes. +Result splitArguments(const QString& text, + Platform platform = currentPlatform()); + +struct DirectCommand { + QStringList argv; + QHash env; +}; + +// Expands wrapper and arguments into argv plus extra environment variables. +// Leading KEY=VALUE tokens in the wrapper set environment variables. %command% +// marks where the game goes; without it the wrapper is a prefix. Fails on +// unbalanced quotes. +Result buildDirectCommand(const QString& exe, + const QString& wrapper, + const QString& args, + Platform platform = currentPlatform()); + +// Locates the RimWorld executable inside `game_path`, preferring modern Waylith +// binaries over leftover Unity files on disk. +std::optional findExecutable(const QString& gamePath, + Platform platform = currentPlatform()); + +// Writes steam_appid.txt containing the RimWorld Steam app id into the game +// folder, unless it already exists. On macOS the file lives in the game +// directory's parent. +void ensureSteamAppid(const QString& gamePath, + Platform platform = currentPlatform()); + +// The steam:// URL that launches RimWorld with the given launch arguments. +QString steamRunUrl(const QString& launchArgs); + +// Hands the steam:// URL to the desktop's handler. Fails when no opener could +// be started. +Result openSteamUrl(const QString& launchArgs); + +// A running process seen on the host. +struct ProcessInfo { + qint64 pid = 0; + QString name; + bool zombie = false; +}; + +// Enumerates processes; an injectable double replaces this in tests. +using ProcessLister = std::function()>; + +// Reads /proc on Linux, Toolhelp32Snapshot on Windows, libproc on macOS. +std::vector hostProcessLister(); + +// Returns true when a non-zombie RimWorld game process is currently running. +bool isGameRunning(const ProcessLister& lister = hostProcessLister); + +// Returns the set of non-zombie RimWorld PIDs found by `lister`. +QSet liveGamePids(const ProcessLister& lister = hostProcessLister); + +struct MonitorTiming { + std::chrono::milliseconds pollStart{500}; + std::chrono::milliseconds pollExit{2000}; +}; + +// Tracks the game process lifecycle: detects when a newly spawned game process +// starts, and when all game processes have finished. Process listing runs on +// the thread pool, so awaiting the methods never blocks the event loop. +class GameProcessMonitor { + public: + explicit GameProcessMonitor(ProcessLister lister = hostProcessLister, + MonitorTiming timing = {}); + ~GameProcessMonitor(); + + GameProcessMonitor(const GameProcessMonitor&) = delete; + GameProcessMonitor& operator=(const GameProcessMonitor&) = delete; + + // Records the game PIDs already running, so they are not counted as newly + // started. + QCoro::Task captureBaseline(); + + // Takes ownership of the spawned process; it must already be started. + void attachProcess(std::unique_ptr proc); + + // Waits until a non-baseline game process appears. Returns false if the + // timeout expires or the spawned process dies before a game process appears. + QCoro::Task waitForStart( + std::chrono::milliseconds timeout = std::chrono::milliseconds(60'000)); + + // Waits until every non-baseline game process exits. Returns the spawned + // process's exit code if one was attached and has exited, or nullopt + // otherwise. + QCoro::Task> waitForExit(); + + private: + bool launchedAlive() const; + // Forgets the spawned process. A child still running is released rather than + // destroyed, because destroying a QProcess kills its child and the game must + // outlive the manager. + void dropProcess(); + + ProcessLister m_lister; + MonitorTiming m_timing; + QSet m_baseline; + std::unique_ptr m_proc; +}; + +} // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.cpp b/src/core/services/metadata_cache.cpp new file mode 100644 index 0000000..7c29577 --- /dev/null +++ b/src/core/services/metadata_cache.cpp @@ -0,0 +1,668 @@ +#include "core/services/metadata_cache.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/migrator.hpp" +#include "core/models/metadata/structures.hpp" + +namespace px::modrim::services { + +using providers::CachedMod; +using providers::CacheLookup; +using providers::ModCandidate; + +namespace { + +constexpr int kSchemaVersion = 2; +constexpr qsizetype kFetchChunk = 500; +constexpr double kMtimeTolerance = 1e-4; +constexpr int kMaxSymlinkHops = 40; + +constexpr char kSchema[] = R"sql( + CREATE TABLE IF NOT EXISTS mod_metadata ( + mod_path TEXT PRIMARY KEY, + about_path TEXT NOT NULL, + mtime REAL NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + target_version TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mm_about + ON mod_metadata(about_path); +)sql"; + +Result exec(QSqlDatabase& db, const QString& sql) { + QSqlQuery query(db); + if (!query.exec(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + return {}; +} + +// ---- Path normalization +// ------------------------------------------------------------------- + +QString joinPath(const QString& dir, const QString& name) { + return dir.endsWith(u'/') ? dir + name : dir + u'/' + name; +} + +// "/" for POSIX paths, "C:/" for drive paths. +QString rootOf(const QString& absolute) { + if (absolute.size() >= 3 && absolute[0].isLetter() && absolute[1] == u':' && + absolute[2] == u'/') { + return absolute.left(3); + } + return QStringLiteral("/"); +} + +QString parentOf(const QString& resolved, const QString& root) { + if (resolved.size() <= root.size()) { + return root; + } + const QString parent = resolved.left(resolved.lastIndexOf(u'/')); + return parent.size() < root.size() ? root : parent; +} + +// Resolves symlinks component by component, consuming ".." after the link +// target has been followed, as Python's posixpath.realpath does. +QString resolvePath(const QString& path) { + QString absolute = QDir::fromNativeSeparators(path); + if (!QDir::isAbsolutePath(absolute)) { + absolute = joinPath(QDir::currentPath(), absolute); + } + const QString root = rootOf(absolute); + QStringList pending = + absolute.mid(root.size()).split(u'/', Qt::SkipEmptyParts); + QString resolved = root; + int hops = 0; + while (!pending.isEmpty()) { + const QString name = pending.takeFirst(); + if (name == QLatin1String(".")) { + continue; + } + if (name == QLatin1String("..")) { + resolved = parentOf(resolved, root); + continue; + } + const QString candidate = joinPath(resolved, name); + if (!QFileInfo(candidate).isSymLink()) { + resolved = candidate; + continue; + } + if (++hops > kMaxSymlinkHops) { + return candidate; + } + const QString target = + QDir::fromNativeSeparators(QFile::symLinkTarget(candidate)); + if (QDir::isAbsolutePath(target)) { + resolved = root; + pending = + target.mid(rootOf(target).size()).split(u'/', Qt::SkipEmptyParts) + + pending; + } else { + pending = target.split(u'/', Qt::SkipEmptyParts) + pending; + } + } + return resolved; +} + +// ---- Payload (de)serialization +// ------------------------------------------------------------ + +QJsonArray sortedArray(const CaseInsensitiveSet& set) { + QStringList values; + values.reserve(set.size()); + for (const auto& item : set) { + values.append(item.str()); + } + values.sort(); + return QJsonArray::fromStringList(values); +} + +QJsonObject serializeMod(const AboutXmlMod& mod) { + QJsonObject object; + object[QStringLiteral("name")] = mod.name; + object[QStringLiteral("package_id")] = mod.packageId.str(); + object[QStringLiteral("authors")] = QJsonArray::fromStringList(mod.authors); + object[QStringLiteral("mod_version")] = mod.modVersion; + object[QStringLiteral("mod_icon_path")] = mod.modIconPath + ? QJsonValue(*mod.modIconPath) + : QJsonValue(QJsonValue::Null); + object[QStringLiteral("steam_app_id")] = + QJsonValue(static_cast(mod.steamAppId)); + object[QStringLiteral("url")] = mod.url; + object[QStringLiteral("description")] = mod.description; + QStringList versions = mod.supportedVersions.values(); + versions.sort(); + object[QStringLiteral("supported_versions")] = + QJsonArray::fromStringList(versions); + object[QStringLiteral("valid")] = mod.valid; + object[QStringLiteral("mtime")] = mod.mtime; + + // Declaration order matters to the checker, which QJsonObject would sort + // away, so dependencies are an array here. deserialize_mod still reads the + // legacy object form. + QJsonArray dependencies; + for (const DependencyMod& dep : mod.aboutRules.dependencies) { + QJsonObject entry; + entry[QStringLiteral("name")] = dep.name; + entry[QStringLiteral("package_id")] = dep.packageId.str(); + entry[QStringLiteral("workshop_url")] = dep.workshopUrl; + entry[QStringLiteral("alternative_package_ids")] = + sortedArray(dep.alternativePackageIds); + dependencies.append(entry); + } + QJsonObject rules; + rules[QStringLiteral("load_before")] = sortedArray(mod.aboutRules.loadBefore); + rules[QStringLiteral("load_after")] = sortedArray(mod.aboutRules.loadAfter); + rules[QStringLiteral("incompatible_with")] = + sortedArray(mod.aboutRules.incompatibleWith); + rules[QStringLiteral("dependencies")] = dependencies; + object[QStringLiteral("about_rules")] = rules; + return object; +} + +QString stringOr(const QJsonObject& object, const char* key, + const QString& fallback) { + const QJsonValue value = object.value(QLatin1StringView(key)); + return value.isString() ? value.toString() : fallback; +} + +QStringList stringList(const QJsonValue& value) { + QStringList values; + for (const QJsonValue& item : value.toArray()) { + if (item.isString()) { + values.append(item.toString()); + } + } + return values; +} + +void addDependency(BaseRules& rules, const QJsonObject& data, + const QString& fallbackPid) { + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(stringOr(data, "package_id", fallbackPid)); + dep.name = stringOr(data, "name", QString()); + dep.workshopUrl = stringOr(data, "workshop_url", QString()); + dep.alternativePackageIds = CaseInsensitiveSet( + stringList(data.value(QLatin1StringView("alternative_package_ids")))); + rules.setDependency(std::move(dep)); +} + +void readDependencies(BaseRules& rules, const QJsonValue& value) { + if (value.isArray()) { + for (const QJsonValue& item : value.toArray()) { + if (item.isObject()) { + addDependency(rules, item.toObject(), QStringLiteral("invalid.mod")); + } + } + } else if (value.isObject()) { + const QJsonObject legacy = value.toObject(); + for (auto it = legacy.constBegin(); it != legacy.constEnd(); ++it) { + if (it.value().isObject()) { + addDependency(rules, it.value().toObject(), it.key()); + } + } + } +} + +// The payload as a fresh AboutXmlMod, without a mod path; the caller sets it. +Result> deserializeMod(const QString& payload) { + const QJsonDocument document = QJsonDocument::fromJson(payload.toUtf8()); + if (document.isNull() || !document.isObject()) { + return std::unexpected( + Error{QStringLiteral("payload is not a JSON object")}); + } + const QJsonObject data = document.object(); + + auto mod = std::make_shared(); + mod->name = stringOr(data, "name", QStringLiteral("Unknown Mod Name")); + mod->packageId = CaseInsensitiveStr( + stringOr(data, "package_id", QStringLiteral("invalid.mod"))); + mod->authors = stringList(data.value(QLatin1StringView("authors"))); + mod->modVersion = stringOr(data, "mod_version", QString()); + const QString icon = stringOr(data, "mod_icon_path", QString()); + if (!icon.isEmpty()) { + mod->modIconPath = icon; + } + mod->steamAppId = data.value(QLatin1StringView("steam_app_id")).toInteger(-1); + mod->url = stringOr(data, "url", QString()); + mod->description = stringOr(data, "description", QString()); + const QStringList versions = + stringList(data.value(QLatin1StringView("supported_versions"))); + mod->supportedVersions = QSet(versions.begin(), versions.end()); + mod->valid = data.value(QLatin1StringView("valid")).toBool(true); + mod->mtime = data.value(QLatin1StringView("mtime")).toDouble(0.0); + + const QJsonValue rulesValue = data.value(QLatin1StringView("about_rules")); + const QJsonObject rulesData = + rulesValue.isObject() ? rulesValue.toObject() : QJsonObject{}; + mod->aboutRules.loadBefore = CaseInsensitiveSet( + stringList(rulesData.value(QLatin1StringView("load_before")))); + mod->aboutRules.loadAfter = CaseInsensitiveSet( + stringList(rulesData.value(QLatin1StringView("load_after")))); + mod->aboutRules.incompatibleWith = CaseInsensitiveSet( + stringList(rulesData.value(QLatin1StringView("incompatible_with")))); + readDependencies(mod->aboutRules, + rulesData.value(QLatin1StringView("dependencies"))); + return mod; +} + +// ---- Batch bookkeeping +// -------------------------------------------------------------------- + +// Candidates keyed by normalized mod path. Like Python's dict, a repeated key +// keeps its first position and takes the later value. +struct NormalizedCandidates { + QList> entries; + QHash index; + + void insert(QString key, const ModCandidate& candidate) { + if (const auto found = index.constFind(key); found != index.constEnd()) { + entries[*found].second = candidate; + return; + } + index.insert(key, entries.size()); + entries.append({std::move(key), candidate}); + } +}; + +NormalizedCandidates normalizeCandidates( + const QList& candidates) { + QStringList modPaths; + modPaths.reserve(candidates.size()); + for (const ModCandidate& candidate : candidates) { + modPaths.append(candidate.modPath); + } + const QStringList normalized = normalizePaths(modPaths); + NormalizedCandidates result; + for (qsizetype i = 0; i < candidates.size(); ++i) { + result.insert(normalized[i], candidates[i]); + } + return result; +} + +struct StoredRow { + QString modPath; + double mtime = 0.0; + qint64 fileSize = 0; + QString targetVersion; + QString payload; +}; + +Result> fetchRows(QSqlDatabase& db, + const QStringList& normPaths) { + QList rows; + for (qsizetype start = 0; start < normPaths.size(); start += kFetchChunk) { + const qsizetype count = std::min(kFetchChunk, normPaths.size() - start); + QStringList placeholders(count, QStringLiteral("?")); + QSqlQuery query(db); + const QString sql = QStringLiteral( + "SELECT mod_path, about_path, mtime, file_size, " + "target_version, payload_json FROM mod_metadata " + "WHERE mod_path IN (%1)") + .arg(placeholders.join(QStringLiteral(","))); + if (!query.prepare(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + for (qsizetype i = 0; i < count; ++i) { + query.addBindValue(normPaths[start + i]); + } + if (!query.exec()) { + return std::unexpected(Error{query.lastError().text()}); + } + while (query.next()) { + rows.append( + StoredRow{query.value(0).toString(), query.value(2).toDouble(), + query.value(3).toLongLong(), query.value(4).toString(), + query.value(5).toString()}); + } + } + return rows; +} + +} // namespace + +QString metadataCachePath(const QString& configDir) { + return QDir(configDir).filePath(QStringLiteral("metadata-cache.db")); +} + +QString normalizePath(const QString& path) { return resolvePath(path); } + +QStringList normalizePaths(const QStringList& paths) { + QHash resolvedParents; + QStringList out; + out.reserve(paths.size()); + for (const QString& raw : paths) { + const QString path = QDir::fromNativeSeparators(raw); + const qsizetype slash = path.lastIndexOf(u'/'); + const QString tail = slash < 0 ? QString() : path.mid(slash + 1); + if (slash < 0 || tail.isEmpty() || tail == QLatin1String(".") || + tail == QLatin1String("..") || QFileInfo(raw).isSymLink()) { + out.append(resolvePath(path)); + continue; + } + const QString head = slash == 0 ? QStringLiteral("/") : path.left(slash); + auto memo = resolvedParents.constFind(head); + if (memo == resolvedParents.constEnd()) { + memo = resolvedParents.insert(head, resolvePath(head)); + } + out.append(joinPath(*memo, tail)); + } + return out; +} + +MetadataCache::MetadataCache(QString path) + : m_path(std::move(path)), + m_connectionName( + QStringLiteral("px-metadata-cache-%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces))) { + m_worker.setMaxThreadCount(1); + // Keeps the single worker alive between calls, so the connection stays on one + // thread. + m_worker.setExpiryTimeout(-1); +} + +MetadataCache::~MetadataCache() { + providers::runOnPool(&m_worker, [this] { + closeConnection(); + }).waitForFinished(); +} + +QCoro::Task MetadataCache::getMods(QList candidates, + QString targetVersion) { + if (candidates.isEmpty()) { + co_return CacheLookup{}; + } + co_return co_await providers::runOnPool( + &m_worker, [this, candidates = std::move(candidates), + targetVersion = std::move(targetVersion)] { + return lookup(candidates, targetVersion); + }); +} + +QCoro::Task MetadataCache::putMods(QList entries, + QString targetVersion) { + if (entries.isEmpty()) { + co_return; + } + co_await providers::runOnPool( + &m_worker, [this, entries = std::move(entries), + targetVersion = std::move(targetVersion)] { + store(entries, targetVersion); + }); +} + +QCoro::Task MetadataCache::pruneDisappeared(QSet liveModPaths) { + co_await providers::runOnPool( + &m_worker, [this, live = std::move(liveModPaths)] { prune(live); }); +} + +QCoro::Task MetadataCache::close() { + co_await providers::runOnPool(&m_worker, [this] { closeConnection(); }); +} + +void MetadataCache::closeConnection() { + if (m_connection) { + m_connection->close(); + m_connection.reset(); + QSqlDatabase::removeDatabase(m_connectionName); + } +} + +QSqlDatabase* MetadataCache::openConnection() { + if (m_disabled) { + return nullptr; + } + if (m_connection) { + return &*m_connection; + } + + if (!QDir().mkpath(QFileInfo(m_path).absolutePath())) { + spdlog::warn( + "Metadata cache unavailable at {}: cannot create its directory. " + "Disabling cache.", + m_path.toStdString()); + m_disabled = true; + return nullptr; + } + if (auto opened = openDatabase(); !opened) { + spdlog::warn( + "Corrupt metadata cache database at {}: {}. Attempting recovery.", + m_path.toStdString(), opened.error().message.toStdString()); + removeDatabaseFiles(); + if (auto reopened = openDatabase(); !reopened) { + spdlog::error( + "Failed to re-initialize metadata cache at {}: {}. Disabling cache.", + m_path.toStdString(), reopened.error().message.toStdString()); + m_disabled = true; + return nullptr; + } + } + return &*m_connection; +} + +Result MetadataCache::openDatabase() { + Result ready; + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName); + db.setDatabaseName(m_path); + if (db.open()) { + ready = Result{}; + } else { + ready = std::unexpected(Error{db.lastError().text()}); + } + for (const char* pragma : + {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { + if (!ready) { + break; + } + ready = exec(db, QString::fromLatin1(pragma)); + } + if (ready) { + MigrationSteps steps; + steps[kSchemaVersion] = [&db] { + return exec(db, QStringLiteral("DELETE FROM mod_metadata")); + }; + ready = + ensureSchema(db, QString::fromLatin1(kSchema), kSchemaVersion, steps, + m_path, QStringLiteral("metadata cache schema")); + } + if (ready) { + m_connection = db; + return {}; + } + db.close(); + } + // Dropped only once no handle to the connection remains. + QSqlDatabase::removeDatabase(m_connectionName); + return ready; +} + +void MetadataCache::removeDatabaseFiles() const { + for (const QString& file : {m_path, m_path + QStringLiteral("-wal"), + m_path + QStringLiteral("-shm")}) { + QFile::remove(file); + } +} + +CacheLookup MetadataCache::lookup(const QList& candidates, + const QString& targetVersion) { + QSqlDatabase* db = openConnection(); + if (!db) { + return CacheLookup{{}, candidates}; + } + + const NormalizedCandidates normalized = normalizeCandidates(candidates); + QStringList keys; + keys.reserve(normalized.entries.size()); + for (const auto& entry : normalized.entries) { + keys.append(entry.first); + } + + auto rows = fetchRows(*db, keys); + if (!rows) { + spdlog::warn("Error reading metadata cache: {}", + rows.error().message.toStdString()); + return CacheLookup{{}, candidates}; + } + + CacheLookup result; + QSet found; + for (const StoredRow& row : *rows) { + const auto index = normalized.index.constFind(row.modPath); + if (index == normalized.index.constEnd()) { + continue; + } + const ModCandidate& candidate = normalized.entries[*index].second; + if (std::abs(row.mtime - candidate.mtime) >= kMtimeTolerance || + row.fileSize != candidate.size || row.targetVersion != targetVersion) { + continue; + } + auto mod = deserializeMod(row.payload); + if (!mod) { + spdlog::warn("Corrupted metadata cache payload for {}: {}", + row.modPath.toStdString(), + mod.error().message.toStdString()); + continue; + } + if (auto set = (*mod)->setModPath(candidate.modPath); !set) { + continue; + } + result.cached.append(*mod); + found.insert(row.modPath); + } + for (const auto& entry : normalized.entries) { + if (!found.contains(entry.first)) { + result.missing.append(entry.second); + } + } + return result; +} + +void MetadataCache::store(const QList& entries, + const QString& targetVersion) { + QSqlDatabase* db = openConnection(); + if (!db) { + return; + } + + struct Row { + QString modPath; + QString aboutPath; + double mtime; + qint64 fileSize; + QString payload; + }; + QList rows; + for (const CachedMod& entry : entries) { + if (!entry.mod->modPath()) { + continue; + } + const QJsonDocument document(serializeMod(*entry.mod)); + rows.append( + Row{normalizePath(*entry.mod->modPath()), + normalizePath(entry.candidate.aboutXmlPath), entry.candidate.mtime, + entry.candidate.size, + QString::fromUtf8(document.toJson(QJsonDocument::Compact))}); + } + + QSqlQuery query(*db); + if (!query.prepare(QStringLiteral( + "INSERT OR REPLACE INTO mod_metadata " + "(mod_path, about_path, mtime, file_size, target_version, " + "payload_json) VALUES (?, ?, ?, ?, ?, ?)"))) { + spdlog::warn("Error writing to metadata cache: {}", + query.lastError().text().toStdString()); + return; + } + db->transaction(); + for (const Row& row : rows) { + query.addBindValue(row.modPath); + query.addBindValue(row.aboutPath); + query.addBindValue(row.mtime); + query.addBindValue(row.fileSize); + query.addBindValue(targetVersion); + query.addBindValue(row.payload); + if (!query.exec()) { + spdlog::warn("Error writing to metadata cache: {}", + query.lastError().text().toStdString()); + db->rollback(); + return; + } + } + if (!db->commit()) { + spdlog::warn("Error writing to metadata cache: {}", + db->lastError().text().toStdString()); + } +} + +void MetadataCache::prune(const QSet& liveModPaths) { + QSqlDatabase* db = openConnection(); + if (!db) { + return; + } + + QStringList disappeared; + { + QSqlQuery query(*db); + if (!query.exec(QStringLiteral("SELECT mod_path FROM mod_metadata"))) { + spdlog::warn("Error pruning metadata cache: {}", + query.lastError().text().toStdString()); + return; + } + while (query.next()) { + const QString path = query.value(0).toString(); + if (!liveModPaths.contains(path)) { + disappeared.append(path); + } + } + } + + if (disappeared.isEmpty()) { + return; + } + spdlog::debug("Pruning {} disappeared mods from metadata cache", + disappeared.size()); + + QSqlQuery query(*db); + if (!query.prepare( + QStringLiteral("DELETE FROM mod_metadata WHERE mod_path = ?"))) { + spdlog::warn("Error pruning metadata cache: {}", + query.lastError().text().toStdString()); + return; + } + db->transaction(); + for (const QString& path : disappeared) { + query.addBindValue(path); + if (!query.exec()) { + spdlog::warn("Error pruning metadata cache: {}", + query.lastError().text().toStdString()); + db->rollback(); + return; + } + } + if (!db->commit()) { + spdlog::warn("Error pruning metadata cache: {}", + db->lastError().text().toStdString()); + } +} + +} // namespace px::modrim::services diff --git a/src/core/services/metadata_cache.hpp b/src/core/services/metadata_cache.hpp new file mode 100644 index 0000000..10b67a3 --- /dev/null +++ b/src/core/services/metadata_cache.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "core/providers/base.hpp" + +namespace px::modrim::services { + +// The cache database inside the config directory. +QString metadataCachePath(const QString& configDir); + +// The symlink-resolved absolute form of `path`, as Python's os.path.realpath +// gives it. The path need not exist: components that do not exist are kept as +// written. +QString normalizePath(const QString& path); + +// normalize_path for many paths; each shared parent directory is resolved once. +QStringList normalizePaths(const QStringList& paths); + +// Persistent SQLite cache of parsed About.xml metadata (metadata-cache.db). All +// database work runs on one private worker thread, which owns the connection, +// so the connection is never used from two threads at once. A database that +// cannot be opened is recovered by deleting it; if that fails too, the cache +// disables itself and every lookup reports a miss. +class MetadataCache final : public providers::MetadataCacheStore { + public: + explicit MetadataCache(QString path); + ~MetadataCache() override; + + QCoro::Task getMods( + QList candidates, + QString targetVersion) override; + QCoro::Task putMods(QList entries, + QString targetVersion) override; + + // Removes entries whose normalized mod path is not in `live_mod_paths`. + QCoro::Task pruneDisappeared(QSet liveModPaths); + + // Closes the connection; the next operation reopens it. + QCoro::Task close(); + + private: + void closeConnection(); + QSqlDatabase* openConnection(); + Result openDatabase(); + void removeDatabaseFiles() const; + + providers::CacheLookup lookup( + const QList& candidates, + const QString& targetVersion); + void store(const QList& entries, + const QString& targetVersion); + void prune(const QSet& liveModPaths); + + QString m_path; + QString m_connectionName; + QThreadPool m_worker; + std::optional m_connection; // worker thread only + bool m_disabled = false; // worker thread only +}; + +} // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.cpp b/src/core/services/mod_discovery.cpp new file mode 100644 index 0000000..bf14688 --- /dev/null +++ b/src/core/services/mod_discovery.cpp @@ -0,0 +1,64 @@ +#include "core/services/mod_discovery.hpp" + +#include +#include +#include +#include + +#include "core/mods_config.hpp" + +namespace px::modrim::services { + +namespace { + +QString stripSteam(QString value) { + if (value.endsWith(QLatin1String("_steam"), Qt::CaseInsensitive)) { + value.chop(6); + } + return value.toLower(); +} + +} // namespace + +QStringList resolveActiveUuids(const QHash& allMods, + const QString& configFolder) { + if (configFolder.isEmpty()) { + return {}; + } + const QString path = + QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); + const auto data = parseModsConfig(path); + if (!data) { + return {}; + } + + // Sort UUIDs so the first-match tiebreaker is deterministic across hash + // iterations. + QStringList uuids = allMods.keys(); + std::ranges::sort(uuids); + + QHash pidToUuid; + for (const QString& uuid : uuids) { + const auto mod = + std::dynamic_pointer_cast(allMods.value(uuid)); + if (!mod) { + continue; + } + const QString pid = stripSteam(mod->packageId.str()); + if (!pidToUuid.contains(pid)) { + pidToUuid.insert(pid, uuid); + } + } + + QStringList active; + for (const CaseInsensitiveStr& pid : data->activeMods) { + const QString normalized = stripSteam(pid.str()); + if (const auto found = pidToUuid.constFind(normalized); + found != pidToUuid.constEnd()) { + active.append(*found); + } + } + return active; +} + +} // namespace px::modrim::services diff --git a/src/core/services/mod_discovery.hpp b/src/core/services/mod_discovery.hpp new file mode 100644 index 0000000..0cef003 --- /dev/null +++ b/src/core/services/mod_discovery.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +#include "core/models/metadata/structures.hpp" + +namespace px::modrim::services { + +// Parses ModsConfig.xml inside `config_folder` and returns the ordered list of +// UUIDs matching active mods. If multiple discovered mods share a package id, +// the first match (in sorted UUID order) is chosen. The "_steam" suffix on +// package ids is ignored. +QStringList resolveActiveUuids(const QHash& allMods, + const QString& configFolder); + +} // namespace px::modrim::services diff --git a/src/core/services/sort_service.cpp b/src/core/services/sort_service.cpp new file mode 100644 index 0000000..dd8286f --- /dev/null +++ b/src/core/services/sort_service.cpp @@ -0,0 +1,119 @@ +#include "core/services/sort_service.hpp" + +#include +#include +#include + +#include + +#include "core/checker/sort.hpp" +#include "core/context.hpp" +#include "core/profiler.hpp" +#include "core/services/diagnostics_service.hpp" + +namespace px::modrim { + +namespace { + +std::shared_ptr aboutXml(const ListedModPtr& mod) { + return std::dynamic_pointer_cast(mod); +} + +} // namespace + +SortService::SortService(CoreContext& ctx, DiagnosticsService& diagnostics) + : m_ctx(ctx), m_diagnostics(diagnostics) {} + +// Python: asyncio.to_thread(topological_sort, ...) -> QtConcurrent::run + +// co_await. The inputs are copies or borrowed from the diagnostics service, +// which is not mutated while awaiting. +QCoro::Task SortService::sortActiveMods() { + ProfileSession session(QStringLiteral("sort")); + Profiler& timer = session.timer(); + const auto activeMods = m_diagnostics.activeModsByPid(); + const auto& graph = m_diagnostics.constraintGraph(); + const sort::SortSettings settings = m_ctx.config().sort; + const sort::CommunityRules* rules = &m_diagnostics.communityRules(); + + const QList sorted = co_await QtConcurrent::run([&] { + return checker::topologicalSort(activeMods, graph, settings, rules, &timer); + }); + co_return orderUuids(sorted, m_ctx.allMods(), m_ctx.activeUuids()); +} + +QStringList SortService::resolveMissingDependencies( + const QSet& activeUuids) const { + return missingDependencyUuids(m_ctx.allMods(), activeUuids); +} + +QStringList SortService::orderUuids(const QList& sortedPids, + const QHash& allMods, + const QStringList& activeUuids) { + QHash pidToUuid; + for (const auto& uuid : activeUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + pidToUuid.insert(about->packageId, uuid); + } + } + + QStringList ordered; + for (const auto& pid : sortedPids) { + if (auto it = pidToUuid.constFind(pid); it != pidToUuid.constEnd()) { + ordered.append(it.value()); + } + } + return ordered; +} + +QStringList SortService::missingDependencyUuids( + const QHash& allMods, + const QSet& activeUuids) { + // Visiting uuids in sorted order keeps the choice between duplicate inactive + // copies stable. + QStringList allUuids = allMods.keys(); + std::ranges::sort(allUuids); + QHash pidToUuid; + for (const auto& uuid : allUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + pidToUuid.insert(about->packageId.str(), uuid); + } + } + + QSet activePids; + for (const auto& uuid : activeUuids) { + if (auto about = aboutXml(allMods.value(uuid))) { + activePids.insert(about->packageId.str()); + } + } + + QStringList missing; + for (const auto& uuid : activeUuids) { + auto about = aboutXml(allMods.value(uuid)); + if (!about) { + continue; + } + for (const auto& dep : about->aboutRules.dependencies) { + const QString depPid = dep.packageId.str(); + if (activePids.contains(depPid)) { + continue; + } + const bool satisfiedByAlternative = std::ranges::any_of( + dep.alternativePackageIds, [&](const CaseInsensitiveStr& alt) { + return activePids.contains(alt.str()); + }); + if (satisfiedByAlternative) { + continue; + } + auto depUuid = pidToUuid.constFind(depPid); + if (depUuid != pidToUuid.constEnd() && + !activeUuids.contains(depUuid.value())) { + missing.append(depUuid.value()); + } + } + } + + missing.removeDuplicates(); + return missing; +} + +} // namespace px::modrim diff --git a/src/core/services/sort_service.hpp b/src/core/services/sort_service.hpp new file mode 100644 index 0000000..e72efba --- /dev/null +++ b/src/core/services/sort_service.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" + +namespace px::modrim { + +class CoreContext; +class DiagnosticsService; + +class SortService { + public: + // Neither argument is owned; both must outlive the service. + SortService(CoreContext& ctx, DiagnosticsService& diagnostics); + + // Orders the active mods by tier, dependencies and load-order rules. Resolves + // to their uuids in that order. + QCoro::Task sortActiveMods(); + + // Uuids of inactive mods that the active mods need and that are not already + // covered. + QStringList resolveMissingDependencies( + const QSet& activeUuids) const; + + // Maps topologically sorted package ids back to the uuids of the active mods + // that declare them. Only the active uuid of a package id is considered, so + // an inactive duplicate never stands in for an active mod. + static QStringList orderUuids(const QList& sortedPids, + const QHash& allMods, + const QStringList& activeUuids); + + // Uuids of inactive mods that satisfy a dependency of an active mod which no + // active mod (or alternative package id) already covers. Duplicates are + // removed, keeping first occurrence. + static QStringList missingDependencyUuids( + const QHash& allMods, + const QSet& activeUuids); + + private: + CoreContext& m_ctx; + DiagnosticsService& m_diagnostics; +}; + +} // namespace px::modrim diff --git a/src/core/services/startup_impact_service/db.cpp b/src/core/services/startup_impact_service/db.cpp new file mode 100644 index 0000000..86a35e3 --- /dev/null +++ b/src/core/services/startup_impact_service/db.cpp @@ -0,0 +1,652 @@ +#include "core/services/startup_impact_service/db.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/migrator.hpp" + +namespace px::modrim::startup_impact { + +namespace { + +// Recency weights: the newest session weighs WEIGHT_SPREAD, the next +// WEIGHT_SPREAD-1, and so on down to 1. Sessions beyond HISTORY_DEPTH are not +// kept. +constexpr int kHistoryDepth = 5; +constexpr int kWeightSpread = 10; + +constexpr int kSchemaVersion = 1; + +// Above this size the file is rebuilt after a store, since freed pages are +// never returned otherwise. +constexpr qint64 kVacuumThresholdBytes = 10 * 1024 * 1024; + +// Schema text is identical to the legacy app's, so both can open the same +// cache.db. +constexpr QLatin1StringView kSchema{R"sql( + CREATE TABLE IF NOT EXISTS startup_impact_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + recorded_at REAL NOT NULL, + loading_time_s REAL NOT NULL DEFAULT 0, + timestamp TEXT NOT NULL DEFAULT '' + ); + + CREATE TABLE IF NOT EXISTS startup_impact_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + package_id TEXT NOT NULL, + mod_name TEXT NOT NULL DEFAULT '', + total_impact_s REAL NOT NULL DEFAULT 0, + metrics_json TEXT NOT NULL DEFAULT '{}', + off_thread_metrics_json TEXT NOT NULL DEFAULT '{}', + off_thread_total_s REAL NOT NULL DEFAULT 0, + FOREIGN KEY (session_id) + REFERENCES startup_impact_sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sie_session + ON startup_impact_entries(session_id); + CREATE INDEX IF NOT EXISTS idx_sie_pid + ON startup_impact_entries(package_id); +)sql"}; + +// Ranks the newest HISTORY_DEPTH sessions and assigns each a recency weight. +// Binds, in order: HISTORY_DEPTH, WEIGHT_SPREAD. Exposes columns session_id and +// weight. +constexpr QLatin1StringView kWeightedSessionsCte{R"sql( + ranked AS ( + SELECT id AS session_id, + ROW_NUMBER() OVER (ORDER BY id DESC) AS rn + FROM startup_impact_sessions + ORDER BY id DESC + LIMIT ? + ), + w AS ( + SELECT session_id, + MAX(? - rn + 1, 1) AS weight + FROM ranked + ) +)sql"}; + +// Wraps a SELECT that joins the weighted sessions as `w`. +QString withWeights(const QString& selectSql) { + return QStringLiteral("WITH ") + QString(kWeightedSessionsCte) + selectSql; +} + +// The CTE's two parameters come first, followed by the query's own. +QVariantList withCteBinds(const QVariantList& rest = {}) { + QVariantList binds{kHistoryDepth, kWeightSpread}; + binds.append(rest); + return binds; +} + +QVariantList toVariants(const QStringList& values) { + QVariantList binds; + binds.reserve(values.size()); + for (const QString& value : values) { + binds.append(value); + } + return binds; +} + +QString placeholderList(qsizetype count) { + QStringList marks(count, QStringLiteral("?")); + return marks.join(QLatin1Char(',')); +} + +// Prepares and runs one statement. The returned query stays positioned on the +// first row. +Result exec(QSqlDatabase& db, const QString& sql, + const QVariantList& binds = {}) { + QSqlQuery query(db); + if (!query.prepare(sql)) { + return std::unexpected(Error{query.lastError().text()}); + } + for (const QVariant& value : binds) { + query.addBindValue(value); + } + if (!query.exec()) { + return std::unexpected(Error{query.lastError().text()}); + } + return query; +} + +Result run(QSqlDatabase& db, const QString& sql, + const QVariantList& binds = {}) { + auto executed = exec(db, sql, binds); + if (!executed) { + return std::unexpected(executed.error()); + } + return {}; +} + +// Stored metric maps hold seconds already. Non-numeric values are skipped. +Result> loadMetricMap(const QString& jsonText) { + const QByteArray bytes = + jsonText.isEmpty() ? QByteArrayLiteral("{}") : jsonText.toUtf8(); + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(bytes, &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return std::unexpected(Error{ + QStringLiteral("Invalid stored metrics: %1").arg(error.errorString())}); + } + QMap values; + const QJsonObject object = document.object(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (it.value().isDouble()) { + values.insert(it.key(), it.value().toDouble()); + } + } + return values; +} + +QString metricsToJson(const QMap& metrics) { + QJsonObject object; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { + object.insert(it.key(), it.value()); + } + return QString::fromUtf8( + QJsonDocument(object).toJson(QJsonDocument::Compact)); +} + +Result rowToMod(const QSqlQuery& row) { + const QString packageId = row.value(0).toString(); + const QString modName = row.value(1).toString(); + // A report without a package id stores the mod name in its place. Read it + // back as absent. + std::optional pid = packageId; + if (packageId == modName && packageId != kBaseGamePid) { + pid = std::nullopt; + } + + auto metrics = loadMetricMap(row.value(3).toString()); + if (!metrics) { + return std::unexpected(metrics.error()); + } + auto offThread = loadMetricMap(row.value(4).toString()); + if (!offThread) { + return std::unexpected(offThread.error()); + } + + return StartupImpactMod{ + .modName = modName, + .packageId = pid, + .totalImpactS = row.value(2).toDouble(), + .metrics = std::move(*metrics), + .offThreadMetrics = std::move(*offThread), + .offThreadTotalImpactS = row.value(5).toDouble(), + }; +} + +Result> loadMods(QSqlDatabase& db, + qlonglong sessionId) { + auto query = exec(db, + "SELECT package_id, mod_name, total_impact_s," + " metrics_json, off_thread_metrics_json, off_thread_total_s" + " FROM startup_impact_entries WHERE session_id = ?", + {sessionId}); + if (!query) { + return std::unexpected(query.error()); + } + QList mods; + while (query->next()) { + auto mod = rowToMod(*query); + if (!mod) { + return std::unexpected(mod.error()); + } + mods.append(std::move(*mod)); + } + return mods; +} + +void closeConnection(StartupImpactDb::Connection& conn) { + if (!conn.open) { + return; + } + { + QSqlDatabase db = QSqlDatabase::database(conn.name, false); + db.close(); + } + // removeDatabase requires that no QSqlDatabase handle for the connection + // remains. + QSqlDatabase::removeDatabase(conn.name); + conn.open = false; + conn.path.clear(); +} + +// Opens the connection on the worker, applying the legacy pragmas and schema. +// Reuses the open connection when the path has not changed. +Result openConnection(StartupImpactDb::Connection& conn, + const QString& path) { + if (conn.open && conn.path == path) { + return QSqlDatabase::database(conn.name, false); + } + closeConnection(conn); + + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), conn.name); + db.setDatabaseName(path); + + const auto prepare = [&db, &path]() -> Result { + if (!db.open()) { + return std::unexpected(Error{db.lastError().text()}); + } + // Order matters: auto_vacuum only takes effect before the first table is + // created. + for (const char* pragma : + {"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", + "PRAGMA foreign_keys=ON", "PRAGMA auto_vacuum=INCREMENTAL"}) { + if (auto applied = run(db, QString::fromLatin1(pragma)); !applied) { + return applied; + } + } + return ensureSchema(db, QString(kSchema), kSchemaVersion, {}, path, + QStringLiteral("DB schema")); + }; + + if (auto ready = prepare(); !ready) { + const Error error = ready.error(); + db = QSqlDatabase(); + QSqlDatabase::removeDatabase(conn.name); + return std::unexpected(error); + } + conn.path = path; + conn.open = true; + return db; +} + +// Read paths return empty results for a missing file rather than creating one. +bool fileExists(const QString& path) { return QFileInfo::exists(path); } + +Result ensureParentDir(const QString& path) { + if (fileExists(path)) { + return {}; + } + const QString dir = QFileInfo(path).absolutePath(); + if (!QDir().mkpath(dir)) { + return std::unexpected( + Error{QStringLiteral("Cannot create directory %1").arg(dir)}); + } + return {}; +} + +Result insertReport(QSqlDatabase& db, const StartupImpactReport& report) { + const QString timestamp = + report.timestamp.isNull() ? QStringLiteral("") : report.timestamp; + auto session = + exec(db, + "INSERT INTO startup_impact_sessions" + " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", + {QDateTime::currentMSecsSinceEpoch() / 1000.0, report.loadingTimeS, + timestamp}); + if (!session) { + return std::unexpected(session.error()); + } + const qlonglong sessionId = session->lastInsertId().toLongLong(); + + for (const StartupImpactMod& mod : report.mods) { + const bool hasPackageId = mod.packageId && !mod.packageId->isEmpty(); + const QString name = + mod.modName.isNull() ? QStringLiteral("") : mod.modName; + const QString pid = hasPackageId ? *mod.packageId : name; + auto entry = + run(db, + "INSERT INTO startup_impact_entries" + " (session_id, package_id, mod_name, total_impact_s," + " metrics_json, off_thread_metrics_json, off_thread_total_s)" + " VALUES (?, ?, ?, ?, ?, ?, ?)", + {sessionId, pid, name, mod.totalImpactS, metricsToJson(mod.metrics), + metricsToJson(mod.offThreadMetrics), mod.offThreadTotalImpactS}); + if (!entry) { + return entry; + } + } + + return run( + db, + "DELETE FROM startup_impact_sessions WHERE id NOT IN" + " (SELECT id FROM startup_impact_sessions ORDER BY id DESC LIMIT ?)", + {kHistoryDepth}); +} + +Result compact(QSqlDatabase& db, const QString& path) { + // Checkpoint the WAL, then reclaim free pages. The file is only rebuilt when + // it has grown. + if (auto checkpointed = run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); + !checkpointed) { + return checkpointed; + } + if (auto vacuumed = run(db, "PRAGMA incremental_vacuum"); !vacuumed) { + return vacuumed; + } + if (QFileInfo(path).size() > kVacuumThresholdBytes) { + return run(db, "VACUUM"); + } + return {}; +} + +// Runs `work` inside one transaction, rolling back when it fails. +template +Result inTransaction(QSqlDatabase& db, Work&& work) { + if (!db.transaction()) { + return std::unexpected(Error{db.lastError().text()}); + } + if (auto done = work(); !done) { + db.rollback(); + return done; + } + if (!db.commit()) { + const Error error{db.lastError().text()}; + db.rollback(); + return std::unexpected(error); + } + return {}; +} + +Result storeReportSync(StartupImpactDb::Connection& conn, + const QString& path, + const StartupImpactReport& report) { + if (auto dir = ensureParentDir(path); !dir) { + return dir; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + if (auto stored = inTransaction(db, [&] { return insertReport(db, report); }); + !stored) { + return stored; + } + return compact(db, path); +} + +Result> getLatestReportSync( + StartupImpactDb::Connection& conn, const QString& path) { + if (!fileExists(path)) { + return std::nullopt; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + + auto session = exec(db, + "SELECT id, loading_time_s, timestamp" + " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1"); + if (!session) { + return std::unexpected(session.error()); + } + if (!session->next()) { + return std::nullopt; + } + const qlonglong sessionId = session->value(0).toLongLong(); + StartupImpactReport report; + report.loadingTimeS = session->value(1).toDouble(); + report.timestamp = session->value(2).toString(); + + auto mods = loadMods(db, sessionId); + if (!mods) { + return std::unexpected(mods.error()); + } + report.mods = std::move(*mods); + return report; +} + +Result averageFor(QSqlDatabase& db, const QString& packageId) { + auto query = + exec(db, + withWeights("SELECT COALESCE(SUM(e.total_impact_s * w.weight) * 1.0" + " / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " WHERE e.package_id = ?"), + withCteBinds({packageId})); + if (!query) { + return std::unexpected(query.error()); + } + return query->next() ? query->value(0).toDouble() : 0.0; +} + +Result> totalsFor( + QSqlDatabase& db, const QStringList& packageIds) { + auto query = exec(db, + withWeights("SELECT e.package_id," + " COALESCE(SUM(e.total_impact_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)," + " COALESCE(SUM(e.off_thread_total_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " WHERE e.package_id IN (" + + placeholderList(packageIds.size()) + + ") GROUP BY e.package_id"), + withCteBinds(toVariants(packageIds))); + if (!query) { + return std::unexpected(query.error()); + } + QHash totals; + while (query->next()) { + totals.insert( + query->value(0).toString(), + ThreadAverages{query->value(1).toDouble(), query->value(2).toDouble()}); + } + return totals; +} + +Result> allAveragesFor(QSqlDatabase& db) { + auto query = exec(db, + withWeights("SELECT e.package_id," + " COALESCE(SUM(e.total_impact_s * w.weight)" + " * 1.0 / NULLIF(SUM(w.weight), 0), 0)" + " FROM startup_impact_entries e" + " JOIN w ON w.session_id = e.session_id" + " GROUP BY e.package_id"), + withCteBinds()); + if (!query) { + return std::unexpected(query.error()); + } + QHash averages; + while (query->next()) { + averages.insert(query->value(0).toString(), query->value(1).toDouble()); + } + return averages; +} + +Result clearSync(StartupImpactDb::Connection& conn, const QString& path) { + if (!fileExists(path)) { + return {}; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + if (auto cleared = inTransaction( + db, + [&]() -> Result { + if (auto entries = run(db, "DELETE FROM startup_impact_entries"); + !entries) { + return entries; + } + return run(db, "DELETE FROM startup_impact_sessions"); + }); + !cleared) { + return cleared; + } + if (auto vacuumed = run(db, "VACUUM"); !vacuumed) { + return vacuumed; + } + return run(db, "PRAGMA wal_checkpoint(TRUNCATE)"); +} + +} // namespace + +QString dbPath(const QString& configDir) { + return QDir(configDir).filePath(QStringLiteral("cache.db")); +} + +StartupImpactDb::StartupImpactDb() { + // A single thread keeps the connection on one thread. Idle threads must not + // expire, since the connection is bound to the thread that opened it. + m_pool.setMaxThreadCount(1); + m_pool.setExpiryTimeout(-1); + m_conn.name = QStringLiteral("startup_impact_%1") + .arg(QUuid::createUuid().toString(QUuid::WithoutBraces)); +} + +StartupImpactDb::~StartupImpactDb() { closeSync(); } + +QCoro::Task> StartupImpactDb::storeReport( + QString path, StartupImpactReport report) { + co_return co_await QtConcurrent::run( + &m_pool, [this, path = std::move(path), report = std::move(report)] { + return storeReportSync(m_conn, path, report); + }); +} + +QCoro::Task>> +StartupImpactDb::getLatestReport(QString path) { + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { + return getLatestReportSync(m_conn, path); + }); +} + +QCoro::Task> StartupImpactDb::getAverage(QString path, + QString packageId) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path), + packageId = std::move(packageId)]() -> Result { + if (!fileExists(path)) { + return 0.0; + } + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return averageFor(db, packageId); + }); +} + +QCoro::Task>> +StartupImpactDb::getTotalsFor(QString path, QStringList packageIds) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path), packageIds = std::move(packageIds)]() + -> Result> { + // Ids without history are left out, so callers treat them as zero. + if (packageIds.isEmpty() || !fileExists(path)) { + return QHash{}; + } + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return totalsFor(db, packageIds); + }); +} + +QCoro::Task>> StartupImpactDb::getAllAverages( + QString path) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path)]() -> Result> { + if (!fileExists(path)) { + return QHash{}; + } + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return allAveragesFor(db); + }); +} + +QCoro::Task> StartupImpactDb::getLatestWithAverages( + QString path, QStringList activePids, std::optional selectedPid) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path), activePids = std::move(activePids), + selectedPid = std::move(selectedPid)]() -> Result { + LatestWithAverages result; + for (const QString& pid : activePids) { + result.totals.insert(pid, ThreadAverages{}); + } + if (!fileExists(path)) { + return result; + } + + auto latest = getLatestReportSync(m_conn, path); + if (!latest) { + return std::unexpected(latest.error()); + } + result.report = std::move(*latest); + + auto opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + + auto base = averageFor(db, QString(kBaseGamePid)); + if (!base) { + return std::unexpected(base.error()); + } + result.baseGameAvg = *base; + + if (!activePids.isEmpty()) { + auto totals = totalsFor(db, activePids); + if (!totals) { + return std::unexpected(totals.error()); + } + for (auto it = totals->cbegin(); it != totals->cend(); ++it) { + result.totals.insert(it.key(), it.value()); + } + } + + if (selectedPid) { + auto selected = averageFor(db, *selectedPid); + if (!selected) { + return std::unexpected(selected.error()); + } + result.selectedAvg = *selected; + } + return result; + }); +} + +QCoro::Task> StartupImpactDb::clear(QString path) { + co_return co_await QtConcurrent::run(&m_pool, [this, path = std::move(path)] { + return clearSync(m_conn, path); + }); +} + +QCoro::Task StartupImpactDb::close() { + co_await QtConcurrent::run(&m_pool, [this] { closeConnection(m_conn); }); +} + +void StartupImpactDb::closeSync() { + QtConcurrent::run(&m_pool, [this] { + closeConnection(m_conn); + }).waitForFinished(); +} + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/db.hpp b/src/core/services/startup_impact_service/db.hpp new file mode 100644 index 0000000..56cebee --- /dev/null +++ b/src/core/services/startup_impact_service/db.hpp @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "core/error.hpp" +#include "core/services/startup_impact_service/models.hpp" + +namespace px::modrim::startup_impact { + +// Average on-thread and off-thread seconds for one package id. +struct ThreadAverages { + double onThread = 0.0; + double offThread = 0.0; +}; + +struct LatestWithAverages { + std::optional report; + double baseGameAvg = 0.0; + // Keyed by every requested package id; ids without history map to zeros. + QHash totals; + double selectedAvg = 0.0; +}; + +// `cache.db` in the config directory. Shared with the metadata cache, which +// owns other tables. +QString dbPath(const QString& configDir); + +// Startup impact history in SQLite, kept to the most recent sessions. Averages +// weight newer sessions more heavily. The schema matches the legacy Python app +// so either can read the file. +// +// All database work runs on one worker thread, which owns the single +// connection. Calls may therefore be made from the Qt event loop without +// blocking it, and need no further locking. +class StartupImpactDb { + public: + // The open connection, identified by a name unique to this instance. Only the + // worker thread reads or writes it. + struct Connection { + QString name; + QString path; + bool open = false; + }; + + StartupImpactDb(); + ~StartupImpactDb(); + StartupImpactDb(const StartupImpactDb&) = delete; + StartupImpactDb& operator=(const StartupImpactDb&) = delete; + + QCoro::Task> storeReport(QString path, + StartupImpactReport report); + QCoro::Task>> getLatestReport( + QString path); + QCoro::Task> getAverage(QString path, QString packageId); + QCoro::Task>> getTotalsFor( + QString path, QStringList packageIds); + QCoro::Task>> getAllAverages(QString path); + QCoro::Task> getLatestWithAverages( + QString path, QStringList activePids, std::optional selectedPid); + QCoro::Task> clear(QString path); + + QCoro::Task close(); + // For shutdown paths that cannot await. Blocks until the connection is + // closed. + void closeSync(); + + private: + // The destructor drains the pool through close_sync() before these members + // are destroyed. + QThreadPool m_pool; + Connection m_conn; +}; + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/labels.cpp b/src/core/services/startup_impact_service/labels.cpp new file mode 100644 index 0000000..e879a0a --- /dev/null +++ b/src/core/services/startup_impact_service/labels.cpp @@ -0,0 +1,140 @@ +#include "core/services/startup_impact_service/labels.hpp" + +#include +#include + +namespace px::modrim::startup_impact { + +namespace { + +constexpr QLatin1StringView kPrefix{"LoadingProgress.StartupImpact."}; + +const QHash& metricLabels() { + static const QHash labels{ + {QStringLiteral("ModConstructor"), + QStringLiteral("Running mod constructor")}, + {QStringLiteral("LoadDefs"), QStringLiteral("Loading defs")}, + {QStringLiteral("CombineXml"), + QStringLiteral("Combining XML into a single XML document")}, + {QStringLiteral("TKeySystemParse"), + QStringLiteral("Translation key system parsing")}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), + QStringLiteral("Parsing and processing XML")}, + {QStringLiteral("ErrorCheckPatches"), + QStringLiteral("Error checking patches")}, + {QStringLiteral("LoadPatches"), QStringLiteral("Loading patches")}, + {QStringLiteral("ApplyPatches"), QStringLiteral("Applying patches")}, + {QStringLiteral("RegisterXmlInheritance"), + QStringLiteral("Registering XML inheritance")}, + {QStringLiteral("ResolveXmlInheritance"), + QStringLiteral("Resolving XML inheritance")}, + {QStringLiteral("ClearCachedPatches"), + QStringLiteral("Clearing cached patches")}, + {QStringLiteral("ClearCachedXmlInheritance"), + QStringLiteral("Clearing cached XML inheritance")}, + {QStringLiteral("LanguageDatabaseInitAllMetadata"), + QStringLiteral("Loading language metadata")}, + {QStringLiteral("DefDatabaseAddAllInMods"), + QStringLiteral("Copying all Defs from mods to global databases")}, + {QStringLiteral("ResolveAllWantedCrossReferences"), + QStringLiteral("Resolving cross-references")}, + {QStringLiteral("ResolveAllWantedCrossReferences.NonImplied"), + QStringLiteral("Resolving cross-references between non-implied Defs")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs"), + QStringLiteral("Rebinding DefOfs")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs.Early"), + QStringLiteral("Rebinding DefOfs (early)")}, + {QStringLiteral("DefOfHelperRebindAllDefOfs.Final"), + QStringLiteral("Rebinding DefOfs (final)")}, + {QStringLiteral("TKeySystemBuildMappings"), + QStringLiteral("Translation key system mapping")}, + {QStringLiteral("BackStoryTranslationUtilityLoadAndInjectBackstoryData"), + QStringLiteral("Loading and injecting legacy backstory translations")}, + {QStringLiteral("ModContentPackReloadContentInt"), + QStringLiteral("Loading content")}, + {QStringLiteral("ModContentPackReloadContentInt.AudioClips"), + QStringLiteral("Loading audio clips")}, + {QStringLiteral("ModContentPackReloadContentInt.Textures"), + QStringLiteral("Loading textures")}, + {QStringLiteral("ModContentPackReloadContentInt.Strings"), + QStringLiteral("Loading strings")}, + {QStringLiteral("ModContentPackReloadContentInt.AssetBundles"), + QStringLiteral("Loading asset bundles")}, + {QStringLiteral("LoadedLanguageInjectIntoDataBeforeImpliedDefs"), + QStringLiteral("Injecting language data (early pass)")}, + {QStringLiteral("ColoredTextResetStaticData"), + QStringLiteral("Running global operations (early pass)")}, + {QStringLiteral("DefGeneratorGenerateImpliedDefsPreResolve"), + QStringLiteral("Generating implied Defs (pre-resolve)")}, + {QStringLiteral("ResolveAllWantedCrossReferences.Implied"), + QStringLiteral("Resolving cross-references between implied Defs")}, + {QStringLiteral("PlayDataLoaderResetStaticDataPre"), + QStringLiteral("Global operations (pre-resolve)")}, + {QStringLiteral("ResolveReferences"), + QStringLiteral("Resolving references")}, + {QStringLiteral("DefGeneratorGenerateImpliedDefsPostResolve"), + QStringLiteral("Generating implied Defs (post-resolve)")}, + {QStringLiteral("PlayDataLoaderResetStaticDataPost"), + QStringLiteral("Global operations (post-resolve)")}, + {QStringLiteral("ErrorCheckAllDefs"), + QStringLiteral("Error checking all defs")}, + {QStringLiteral("KeyPrefsInit"), + QStringLiteral("Loading keyboard preferences")}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), + QStringLiteral("Short hash giving")}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), + QStringLiteral("Running delayed initialization task")}, + {QStringLiteral("SolidBioDatabaseLoadAllBios"), + QStringLiteral("Loading all bios")}, + {QStringLiteral("LoadedLanguageInjectIntoDataAfterImpliedDefs"), + QStringLiteral("Injecting language data")}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), + QStringLiteral("Running static constructors")}, + {QStringLiteral("FloatMenuMakerMapInit"), + QStringLiteral("Initializing float menu maker map")}, + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), + QStringLiteral("Texture atlas baking")}, + {QStringLiteral("AbstractFilesystemClearAllCache"), + QStringLiteral("Clearing filesystem cache")}, + {QStringLiteral("LoadModXml"), + QStringLiteral("Loading and parsing mod XML")}, + }; + return labels; +} + +// The exact key, else its last dot-separated segment, else the key itself. +QString baseLabel(const QString& key) { + const auto& labels = metricLabels(); + if (const auto found = labels.constFind(key); found != labels.cend()) { + return *found; + } + const QString tail = key.mid(key.lastIndexOf(u'.') + 1); + if (const auto found = labels.constFind(tail); found != labels.cend()) { + return *found; + } + return key; +} + +} // namespace + +QString stripMetricPrefix(const QString& key) { + return key.startsWith(kPrefix) ? key.mid(kPrefix.size()) : key; +} + +QString metricLabel(const QString& key) { + const QString stripped = stripMetricPrefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + const QString base = bar < 0 ? stripped : stripped.left(bar); + const QString target = bar < 0 ? QString() : stripped.mid(bar + 1); + const QString label = baseLabel(base); + if (target.isEmpty()) { + return label; + } + // Deferred tasks name the method they run after " -> "; only the target type + // is shown. + const qsizetype arrow = target.indexOf(QStringLiteral(" -> ")); + const QString shown = arrow < 0 ? target : target.left(arrow); + return QStringLiteral("%1 (%2)").arg(label, shown); +} + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/labels.hpp b/src/core/services/startup_impact_service/labels.hpp new file mode 100644 index 0000000..cda0735 --- /dev/null +++ b/src/core/services/startup_impact_service/labels.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace px::modrim::startup_impact { + +// Drops the "LoadingProgress.StartupImpact." prefix the game writes before +// every metric key. +QString stripMetricPrefix(const QString& key); + +// Human-readable name for a metric key. A "|Target" suffix, as written for +// deferred tasks, is shown in parentheses after the label. +QString metricLabel(const QString& key); + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.cpp b/src/core/services/startup_impact_service/models.cpp new file mode 100644 index 0000000..c5d1375 --- /dev/null +++ b/src/core/services/startup_impact_service/models.cpp @@ -0,0 +1,81 @@ +#include "core/services/startup_impact_service/models.hpp" + +#include + +namespace px::modrim::startup_impact { + +QString normalizePackageId(QStringView packageId) { + QString result = packageId.toString().toLower(); + constexpr QLatin1StringView kSuffix{"_steam"}; + if (result.endsWith(kSuffix)) { + result.chop(kSuffix.size()); + } + return result; +} + +QString formatImpact(double seconds) { + const double milliseconds = seconds * 1000.0; + const double magnitude = std::abs(milliseconds); + double rounded = std::floor(magnitude); + const double fraction = magnitude - rounded; + // Choose the even neighbour at an exact tie, independent of the FP mode. + if (fraction > 0.5 || (fraction == 0.5 && std::fmod(rounded, 2.0) != 0.0)) { + rounded += 1.0; + } + if (milliseconds < 0.0 && rounded != 0.0) { + rounded = -rounded; + } + return QString::number(rounded, 'f', 0) + QLatin1StringView{"ms"}; +} + +double StartupImpactReport::totalTime() const { + double result = 0.0; + for (const auto& mod : mods) { + result += mod.totalImpactS; + } + return result; +} + +double StartupImpactReport::offThreadTotalTime() const { + double result = 0.0; + for (const auto& mod : mods) { + result += mod.offThreadTotalImpactS; + } + return result; +} + +double StartupImpactReport::modsTotalTime() const { + return totalTime() + offThreadTotalTime(); +} + +double StartupImpactReport::baseGameTime() const { + double result = 0.0; + for (double duration : metrics) { + result += duration; + } + return result; +} + +const StartupImpactMod* StartupImpactReport::find( + const std::optional& packageId, + const std::optional& modName) const { + if (packageId && !packageId->isEmpty()) { + const QString wanted = normalizePackageId(*packageId); + for (const auto& mod : mods) { + if (mod.packageId && *mod.packageId == wanted) { + return &mod; + } + } + } + if (modName && !modName->isEmpty()) { + const QString wanted = modName->toLower(); + for (const auto& mod : mods) { + if (mod.modName.toLower() == wanted) { + return &mod; + } + } + } + return nullptr; +} + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/models.hpp b/src/core/services/startup_impact_service/models.hpp new file mode 100644 index 0000000..9190a75 --- /dev/null +++ b/src/core/services/startup_impact_service/models.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace px::modrim::startup_impact { + +inline constexpr double kImpactWarnThresholdS = 1.0; +inline constexpr double kImpactHighThresholdS = 5.0; +inline constexpr QLatin1StringView kBaseGamePid{"__base_game__"}; + +struct StartupImpactMod { + QString modName; + std::optional packageId; + double totalImpactS = 0.0; + QMap metrics; + QMap offThreadMetrics; + double offThreadTotalImpactS = 0.0; +}; + +struct StartupImpactReport { + QString path; + double loadingTimeS = 0.0; + QList mods; + QString timestamp; + QMap metrics; + double totalImpact = 0.0; + QMap offThreadMetrics; + double offThreadTotalImpact = 0.0; + + double totalTime() const; + double offThreadTotalTime() const; + double modsTotalTime() const; + double baseGameTime() const; + const StartupImpactMod* find(const std::optional& packageId, + const std::optional& modName) const; +}; + +QString normalizePackageId(QStringView packageId); +QString formatImpact(double seconds); + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.cpp b/src/core/services/startup_impact_service/parser.cpp new file mode 100644 index 0000000..03fe6d4 --- /dev/null +++ b/src/core/services/startup_impact_service/parser.cpp @@ -0,0 +1,133 @@ +#include "core/services/startup_impact_service/parser.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace px::modrim::startup_impact { +namespace { + +const glz::json_t& field(const glz::json_t& object, std::string_view name) { + static const glz::json_t kMissing; + const auto& members = object.get_object(); + const auto it = members.find(name); + return it == members.end() ? kMissing : it->second; +} + +double durationSeconds(const glz::json_t& value) { + if (value.is_number()) { + return value.get_number() / 1000.0; + } + if (value.is_string()) { + bool ok = false; + const double number = + QString::fromStdString(value.get_string()).toDouble(&ok); + return ok ? number / 1000.0 : 0.0; + } + return 0.0; +} + +QMap durationMap(const glz::json_t& value) { + QMap result; + if (value.is_object()) { + for (const auto& [name, duration] : value.get_object()) { + if (duration.is_number()) { + result.insert(QString::fromStdString(name), + duration.get_number() / 1000.0); + } + } + } + return result; +} + +} // namespace + +QString getStartupImpactPath(const QString& configFolder) { + const QString folder = QDir::cleanPath(configFolder); + return QDir(QFileInfo(folder).path()) + .filePath(QStringLiteral("StartupImpactData.json")); +} + +std::optional parseStartupImpact(const QString& path) { + QFile input(path); + if (!input.open(QIODevice::ReadOnly)) { + if (!input.exists()) { + spdlog::debug("Startup impact report missing: {}", path.toStdString()); + } else { + spdlog::warn("Cannot open startup impact report {}: {}", + path.toStdString(), input.errorString().toStdString()); + } + return std::nullopt; + } + const QByteArray bytes = input.readAll(); + if (input.error() != QFileDevice::NoError) { + spdlog::warn("Cannot read startup impact report {}: {}", path.toStdString(), + input.errorString().toStdString()); + return std::nullopt; + } + if (!QByteArrayView(bytes).isValidUtf8()) { + spdlog::warn("Startup impact report is not UTF-8: {}", path.toStdString()); + return std::nullopt; + } + const std::string_view text(bytes.constData(), + static_cast(bytes.size())); + glz::json_t root; + if (auto error = glz::read_json(root, text)) { + spdlog::warn("Cannot parse startup impact report {}: {}", + path.toStdString(), glz::format_error(error, text)); + return std::nullopt; + } + if (!root.is_object()) { + spdlog::warn("Startup impact report must be an object: {}", + path.toStdString()); + return std::nullopt; + } + + StartupImpactReport report; + report.path = path; + report.loadingTimeS = durationSeconds(field(root, "loadingTime")); + report.metrics = durationMap(field(root, "metrics")); + report.totalImpact = durationSeconds(field(root, "totalImpact")); + report.offThreadMetrics = durationMap(field(root, "offThreadMetrics")); + report.offThreadTotalImpact = + durationSeconds(field(root, "offThreadTotalImpact")); + const auto& timestamp = field(root, "timestamp"); + if (timestamp.is_string()) { + report.timestamp = QString::fromStdString(timestamp.get_string()); + } + const auto& entries = field(root, "mods"); + if (entries.is_array()) { + report.mods.reserve(static_cast(entries.get_array().size())); + for (const auto& entry : entries.get_array()) { + if (!entry.is_object()) { + continue; + } + const auto& name = field(entry, "modName"); + if (!name.is_string() || name.get_string().empty()) { + continue; + } + StartupImpactMod mod; + mod.modName = QString::fromStdString(name.get_string()); + const auto& packageId = field(entry, "modPackageId"); + if (packageId.is_string() && !packageId.get_string().empty()) { + mod.packageId = + normalizePackageId(QString::fromStdString(packageId.get_string())); + } + mod.totalImpactS = durationSeconds(field(entry, "totalImpact")); + mod.metrics = durationMap(field(entry, "metrics")); + mod.offThreadMetrics = durationMap(field(entry, "offThreadMetrics")); + mod.offThreadTotalImpactS = + durationSeconds(field(entry, "offThreadTotalImpact")); + report.mods.append(std::move(mod)); + } + } + return report; +} + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/parser.hpp b/src/core/services/startup_impact_service/parser.hpp new file mode 100644 index 0000000..6e29592 --- /dev/null +++ b/src/core/services/startup_impact_service/parser.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +#include "core/services/startup_impact_service/models.hpp" + +namespace px::modrim::startup_impact { + +QString getStartupImpactPath(const QString& configFolder); +std::optional parseStartupImpact(const QString& path); + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.cpp b/src/core/services/startup_impact_service/phases.cpp new file mode 100644 index 0000000..7cec902 --- /dev/null +++ b/src/core/services/startup_impact_service/phases.cpp @@ -0,0 +1,95 @@ +#include "core/services/startup_impact_service/phases.hpp" + +#include +#include +#include +#include + +#include "core/services/startup_impact_service/labels.hpp" + +namespace px::modrim::startup_impact { + +namespace { + +const QHash& exactPhases() { + static const QHash phases{ + {QStringLiteral("GlobalTextureAtlasManagerBakeStaticAtlases"), + Phase::kContent}, + {QStringLiteral("ModConstructor"), Phase::kModConstructors}, + {QStringLiteral("StaticConstructorOnStartupUtilityCallAll"), + Phase::kStaticConstructors}, + {QStringLiteral("ExecuteToExecuteWhenFinished"), Phase::kDeferred}, + {QStringLiteral("LoadDefs"), Phase::kDefs}, + {QStringLiteral("LoadModXml"), Phase::kDefs}, + {QStringLiteral("CombineXml"), Phase::kDefs}, + {QStringLiteral("LoadedModManagerParseAndProcessXML"), Phase::kDefs}, + {QStringLiteral("RegisterXmlInheritance"), Phase::kDefs}, + {QStringLiteral("ResolveXmlInheritance"), Phase::kDefs}, + {QStringLiteral("ClearCachedXmlInheritance"), Phase::kDefs}, + {QStringLiteral("DefDatabaseAddAllInMods"), Phase::kDefs}, + {QStringLiteral("ResolveReferences"), Phase::kDefs}, + {QStringLiteral("ErrorCheckAllDefs"), Phase::kDefs}, + {QStringLiteral("ShortHashGiverGiveAllShortHashes"), Phase::kDefs}, + }; + return phases; +} + +// Checked in order after the exact names; the first matching prefix wins. +constexpr std::array, 4> kPrefixPhases{{ + {QLatin1StringView{"ModContentPackReloadContentInt"}, Phase::kContent}, + {QLatin1StringView{"ResolveAllWantedCrossReferences"}, Phase::kDefs}, + {QLatin1StringView{"DefGeneratorGenerateImpliedDefs"}, Phase::kDefs}, + {QLatin1StringView{"DefOfHelperRebindAllDefOfs"}, Phase::kDefs}, +}}; + +} // namespace + +QString phaseLabel(Phase phase) { + switch (phase) { + case Phase::kContent: + return QStringLiteral("Textures & audio"); + case Phase::kModConstructors: + return QStringLiteral("Mod constructors"); + case Phase::kStaticConstructors: + return QStringLiteral("Static constructors"); + case Phase::kPatches: + return QStringLiteral("Patches"); + case Phase::kDefs: + return QStringLiteral("Defs & XML"); + case Phase::kDeferred: + return QStringLiteral("Deferred init"); + case Phase::kOther: + return QStringLiteral("Other"); + } + return QString(); +} + +Phase metricPhase(const QString& key) { + const QString stripped = stripMetricPrefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + const QString name = bar < 0 ? stripped : stripped.left(bar); + + if (const auto found = exactPhases().constFind(name); + found != exactPhases().cend()) { + return *found; + } + if (name.endsWith(QLatin1StringView{"Patches"})) { + return Phase::kPatches; + } + for (const auto& [prefix, phase] : kPrefixPhases) { + if (name.startsWith(prefix)) { + return phase; + } + } + return Phase::kOther; +} + +PhaseTotals phaseTotals(const QMap& metrics) { + PhaseTotals totals{}; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) { + totals[phaseIndex(metricPhase(it.key()))] += it.value(); + } + return totals; +} + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/phases.hpp b/src/core/services/startup_impact_service/phases.hpp new file mode 100644 index 0000000..874e94e --- /dev/null +++ b/src/core/services/startup_impact_service/phases.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::modrim::startup_impact { + +enum class Phase : std::uint8_t { + kContent, + kModConstructors, + kStaticConstructors, + kPatches, + kDefs, + kDeferred, + kOther, +}; + +inline constexpr std::size_t kPhaseCount = 7; + +// Seconds per phase, indexed by `phase_index`. +using PhaseTotals = std::array; + +constexpr std::size_t phaseIndex(Phase phase) { + return static_cast(phase); +} + +QString phaseLabel(Phase phase); + +// Phase a profiler metric key belongs to. Unknown keys fall into Other. +Phase metricPhase(const QString& key); + +// Sums metric seconds per phase. Every phase is present, even when it has no +// metrics. +PhaseTotals phaseTotals(const QMap& metrics); + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.cpp b/src/core/services/startup_impact_service/service.cpp new file mode 100644 index 0000000..ce15b55 --- /dev/null +++ b/src/core/services/startup_impact_service/service.cpp @@ -0,0 +1,121 @@ +#include "core/services/startup_impact_service/service.hpp" + +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/services/startup_impact_service/parser.hpp" + +namespace px::modrim::startup_impact { + +namespace { + +constexpr double kMinBaseGameTimeS = 0.001; + +QString dbPathFor(CoreContext& ctx) { + return dbPath(ctx.configService().directory()); +} + +QStringList normalized(const QStringList& pids) { + QStringList out; + out.reserve(pids.size()); + for (const QString& pid : pids) out.push_back(normalizePackageId(pid)); + return out; +} + +} // namespace + +StartupImpactService::StartupImpactService(CoreContext& ctx) : m_ctx(ctx) {} + +QCoro::Task>> +StartupImpactService::load() { + const std::string& configFolder = m_ctx.config().paths.config_folder; + if (configFolder.empty()) co_return std::optional{}; + + const QString dbPath = dbPathFor(m_ctx); + const QString jsonPath = + getStartupImpactPath(QString::fromStdString(configFolder)); + spdlog::debug("Loading startup impact from {}", jsonPath.toStdString()); + + std::optional report = parseStartupImpact(jsonPath); + if (report) { + const double base = std::max(0.0, report->baseGameTime()); + QList entries = report->mods; + if (base > kMinBaseGameTimeS) { + StartupImpactMod baseGame; + baseGame.modName = QString(kBaseGamePid); + baseGame.packageId = QString(kBaseGamePid); + baseGame.totalImpactS = base; + baseGame.metrics = report->metrics; + baseGame.offThreadMetrics = report->offThreadMetrics; + baseGame.offThreadTotalImpactS = + std::accumulate(report->offThreadMetrics.cbegin(), + report->offThreadMetrics.cend(), 0.0); + entries.push_back(std::move(baseGame)); + } + StartupImpactReport full; + full.path = report->path; + full.loadingTimeS = report->loadingTimeS; + full.mods = std::move(entries); + full.timestamp = report->timestamp; + auto stored = co_await m_db.storeReport(dbPath, std::move(full)); + if (!stored) co_return std::unexpected(stored.error()); + } + co_return report; +} + +QCoro::Task>> +StartupImpactService::getLatestReport() { + co_return co_await m_db.getLatestReport(dbPathFor(m_ctx)); +} + +QCoro::Task> StartupImpactService::estimatedImpactFor( + QString packageId) { + co_return co_await m_db.getAverage(dbPathFor(m_ctx), + normalizePackageId(packageId)); +} + +QCoro::Task> StartupImpactService::estimatedTotal( + QStringList activePids) { + const QString dbPath = dbPathFor(m_ctx); + auto base = co_await m_db.getAverage(dbPath, QString(kBaseGamePid)); + if (!base) co_return std::unexpected(base.error()); + auto totals = co_await m_db.getTotalsFor(dbPath, normalized(activePids)); + if (!totals) co_return std::unexpected(totals.error()); + double modSum = 0.0; + for (const ThreadAverages& t : *totals) modSum += t.onThread + t.offThread; + co_return *base + modSum; +} + +QCoro::Task>> +StartupImpactService::getAllAverages() { + co_return co_await m_db.getAllAverages(dbPathFor(m_ctx)); +} + +QCoro::Task> StartupImpactService::baseGameAverage() { + co_return co_await m_db.getAverage(dbPathFor(m_ctx), QString(kBaseGamePid)); +} + +QCoro::Task> StartupImpactService::snapshot( + QStringList activePids, std::optional selectedPid) { + std::optional selected; + if (selectedPid && !selectedPid->isEmpty()) + selected = normalizePackageId(*selectedPid); + co_return co_await m_db.getLatestWithAverages( + dbPathFor(m_ctx), normalized(activePids), std::move(selected)); +} + +QCoro::Task> StartupImpactService::clear() { + co_return co_await m_db.clear(dbPathFor(m_ctx)); +} + +QCoro::Task StartupImpactService::closeConnection() { + co_await m_db.close(); +} + +void StartupImpactService::closeConnectionSync() { m_db.closeSync(); } + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/startup_impact_service/service.hpp b/src/core/services/startup_impact_service/service.hpp new file mode 100644 index 0000000..17e81a9 --- /dev/null +++ b/src/core/services/startup_impact_service/service.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "core/error.hpp" +#include "core/services/startup_impact_service/db.hpp" +#include "core/services/startup_impact_service/models.hpp" + +namespace px::modrim { +class CoreContext; +} + +namespace px::modrim::startup_impact { + +// Loads the game's startup impact report into the history database and answers +// queries about historical averages and per-session breakdowns. +class StartupImpactService { + public: + // `ctx` is not owned and must outlive the service. + explicit StartupImpactService(CoreContext& ctx); + + // Imports the report from the game's config folder and records it in history. + // Resolves to nullopt when no config folder is set or no report can be read. + QCoro::Task>> load(); + + QCoro::Task>> getLatestReport(); + QCoro::Task> estimatedImpactFor(QString packageId); + // Base-game average plus the on-thread and off-thread averages of each active + // mod. + QCoro::Task> estimatedTotal(QStringList activePids); + QCoro::Task>> getAllAverages(); + QCoro::Task> baseGameAverage(); + QCoro::Task> snapshot( + QStringList activePids, std::optional selectedPid = {}); + QCoro::Task> clear(); + + QCoro::Task closeConnection(); + void closeConnectionSync(); + + private: + CoreContext& m_ctx; + StartupImpactDb m_db; +}; + +} // namespace px::modrim::startup_impact diff --git a/src/core/services/update_service.cpp b/src/core/services/update_service.cpp new file mode 100644 index 0000000..15bd72b --- /dev/null +++ b/src/core/services/update_service.cpp @@ -0,0 +1,173 @@ +#include "core/services/update_service.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace px::modrim::services { + +namespace { + +constexpr int kTransferTimeoutMs = 10'000; + +struct ParsedVersion { + std::vector numbers; + QString suffix; +}; + +// Python's str.strip("-._ ") on both ends. +QString stripSeparators(QString text) { + constexpr QStringView kSeparators = u"-._ "; + while (!text.isEmpty() && kSeparators.contains(text.front())) { + text.removeFirst(); + } + while (!text.isEmpty() && kSeparators.contains(text.back())) { + text.chop(1); + } + return text; +} + +std::optional splitVersion(const QString& text) { + static const QRegularExpression pattern( + QStringLiteral(R"(^v?([0-9]+(?:\.[0-9]+)*)([^+]*))")); + const QRegularExpressionMatch match = pattern.match(text.trimmed()); + if (!match.hasMatch()) { + return std::nullopt; + } + ParsedVersion parsed; + for (const QString& part : match.captured(1).split(u'.')) { + parsed.numbers.push_back(part.toLongLong()); + } + parsed.suffix = stripSeparators(match.captured(2)).toLower(); + return parsed; +} + +// Orders two versions the way the legacy key did: padded numbers, then release +// over pre-release, then the suffix text. +int compareVersions(const ParsedVersion& a, const ParsedVersion& b) { + const std::size_t width = std::max(a.numbers.size(), b.numbers.size()); + for (std::size_t i = 0; i < width; ++i) { + const qint64 left = i < a.numbers.size() ? a.numbers[i] : 0; + const qint64 right = i < b.numbers.size() ? b.numbers[i] : 0; + if (left != right) { + return left < right ? -1 : 1; + } + } + const bool leftRelease = a.suffix.isEmpty(); + const bool rightRelease = b.suffix.isEmpty(); + if (leftRelease != rightRelease) { + return leftRelease ? 1 : -1; + } + if (a.suffix != b.suffix) { + return a.suffix < b.suffix ? -1 : 1; + } + return 0; +} + +struct ReplyDeleter { + void operator()(QNetworkReply* reply) const { reply->deleteLater(); } +}; + +QString stringField(const QJsonObject& object, const char* key) { + const QJsonValue value = object.value(QLatin1StringView(key)); + if (value.isString()) { + return value.toString(); + } + if (value.isDouble()) { + return QString::number(value.toDouble()); + } + return {}; +} + +} // namespace + +std::optional> parseVersion(const QString& text) { + const auto parsed = splitVersion(text); + if (!parsed) { + return std::nullopt; + } + return parsed->numbers; +} + +bool isNewer(const QString& tag, const QString& current) { + const auto latest = splitVersion(tag); + const auto running = splitVersion(current); + if (!latest || !running) { + return false; + } + return compareVersions(*latest, *running) > 0; +} + +UpdateService::UpdateService(QString currentVersion, + QNetworkAccessManager& network, QString releaseUrl) + : m_current(std::move(currentVersion)), + m_network(network), + m_releaseUrl(std::move(releaseUrl)) {} + +QCoro::Task>> UpdateService::check() { + QNetworkRequest request{QUrl(m_releaseUrl)}; + request.setRawHeader("Accept", "application/vnd.github+json"); + request.setRawHeader("User-Agent", + QStringLiteral("PxModRim/%1").arg(m_current).toUtf8()); + request.setTransferTimeout(kTransferTimeoutMs); + + QNetworkReply* pending = m_network.get(request); + std::unique_ptr reply(pending); + co_await pending; + + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected(Error{reply->errorString()}); + } + if (status < 200 || status >= 300) { + co_return std::unexpected( + Error{QStringLiteral("HTTP %1 from %2").arg(status).arg(m_releaseUrl)}); + } + + const QByteArray body = reply->readAll(); + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError) { + co_return std::unexpected(Error{parseError.errorString()}); + } + if (!document.isObject()) { + co_return std::unexpected( + Error{QStringLiteral("Latest release response is not a JSON object")}); + } + + const QJsonObject data = document.object(); + const QString tag = stringField(data, "tag_name"); + const QString url = stringField(data, "html_url"); + if (tag.isEmpty() || url.isEmpty()) { + co_return std::unexpected(Error{ + QStringLiteral("Latest release response lacks tag_name/html_url")}); + } + if (!isNewer(tag, m_current)) { + spdlog::debug("No update: latest {} vs running {}", tag.toStdString(), + m_current.toStdString()); + co_return std::optional{}; + } + + spdlog::info("Update available: {} (running {})", tag.toStdString(), + m_current.toStdString()); + QString name = stringField(data, "name"); + if (name.isEmpty()) { + name = tag; + } + co_return std::optional( + ReleaseInfo{tag, name, stringField(data, "body"), url}); +} + +} // namespace px::modrim::services diff --git a/src/core/services/update_service.hpp b/src/core/services/update_service.hpp new file mode 100644 index 0000000..30f3a26 --- /dev/null +++ b/src/core/services/update_service.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "core/error.hpp" + +class QNetworkAccessManager; + +namespace px::modrim::services { + +inline constexpr QLatin1StringView kLatestReleaseUrl{ + "https://api.github.com/repos/PyXiion/PxModRim/releases/latest"}; + +struct ReleaseInfo { + QString tag; + QString name; + QString notes; + QString url; +}; + +// The numeric components of `text` ("v1.2.3" -> 1, 2, 3), ignoring any suffix; +// nullopt when the text does not start with a version. +std::optional> parseVersion(const QString& text); + +// True when `tag` is a strictly higher release than `current`. A pre-release +// suffix ("-rc1") sorts below the plain release with the same numbers. Versions +// that do not parse are never reported as outdated. +bool isNewer(const QString& tag, const QString& current); + +// Looks up the newest published GitHub release. +class UpdateService { + public: + // `network` is not owned and must outlive the service. `release_url` is the + // latest-release endpoint; the default is the project's own. + UpdateService(QString currentVersion, QNetworkAccessManager& network, + QString releaseUrl = QString(kLatestReleaseUrl)); + + const QString& currentVersion() const noexcept { return m_current; } + + // The latest release when it is newer than the running app, nullopt + // otherwise. Fails on a network error, a non-success HTTP status or a + // malformed response. + QCoro::Task>> check(); + + private: + QString m_current; + QNetworkAccessManager& m_network; + QString m_releaseUrl; +}; + +} // namespace px::modrim::services diff --git a/src/core/sort/community.cpp b/src/core/sort/community.cpp new file mode 100644 index 0000000..c3ce29b --- /dev/null +++ b/src/core/sort/community.cpp @@ -0,0 +1,59 @@ +#include "core/sort/community.hpp" + +#include + +#include + +#include "core/config.hpp" +#include "core/file_io.hpp" + +namespace px::modrim::sort { + +namespace { + +CaseInsensitiveSet toPackageSet( + const std::map& rules) { + CaseInsensitiveSet result; + for (const auto& [pid, _] : rules) { + result.add(QString::fromStdString(pid)); + } + return result; +} + +} // namespace + +CommunityRules toCommunityRules(const ExternalRulesSchema& schema) { + CommunityRules result; + for (const auto& [pid_str, rule] : schema.rules) { + CommunityRule community; + community.packageId = PackageId(QString::fromStdString(pid_str)); + community.loadAfter = toPackageSet(rule.loadAfter); + community.loadBefore = toPackageSet(rule.loadBefore); + community.loadFirst = rule.loadTop.value; + community.loadLast = rule.loadBottom.value; + result.insert(community.packageId, std::move(community)); + } + return result; +} + +Result loadCommunityRules(const QString& jsonPath) { + if (!QFileInfo::exists(jsonPath)) { + return CommunityRules{}; + } + + auto bytes = readFileBytes(jsonPath); + if (!bytes) { + return std::unexpected(bytes.error()); + } + + ExternalRulesSchema schema; + if (auto error = glz::read(schema, *bytes)) { + return std::unexpected(Error{ + QStringLiteral("Failed to parse community rules %1: %2") + .arg(jsonPath, + QString::fromStdString(glz::format_error(error, *bytes)))}); + } + return toCommunityRules(schema); +} + +} // namespace px::modrim::sort diff --git a/src/core/sort/community.hpp b/src/core/sort/community.hpp new file mode 100644 index 0000000..ed05625 --- /dev/null +++ b/src/core/sort/community.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/models.hpp" + +namespace px::modrim::sort { + +// Converts the upstream schema into rules keyed by package id. Only the keys of +// loadAfter and loadBefore matter; their values are ignored. +CommunityRules toCommunityRules(const ExternalRulesSchema& schema); + +// Reads the community rules file. A missing file means no rules; a malformed +// one is an error. +Result loadCommunityRules(const QString& jsonPath); + +} // namespace px::modrim::sort diff --git a/src/core/sort/community_service.cpp b/src/core/sort/community_service.cpp new file mode 100644 index 0000000..746ef9c --- /dev/null +++ b/src/core/sort/community_service.cpp @@ -0,0 +1,245 @@ +#include "core/sort/community_service.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/file_io.hpp" +#include "core/sort/community.hpp" + +namespace px::modrim::sort { + +namespace { + +const QString kRulesFilename = QStringLiteral("communityRules.json"); +const QString kRulesUrl = QStringLiteral( + "https://github.com/RimSort/Community-Rules-Database/archive/refs/heads/" + "main.zip"); + +// Matches the httpx timeouts of the original client: a stalled transfer fails +// after these. +constexpr int kHeadTransferTimeoutMs = 10'000; +constexpr int kGetTransferTimeoutMs = 30'000; +constexpr int kExtractChunkBytes = 64 * 1024; + +Error networkError(const QNetworkReply& reply) { + return Error{reply.errorString()}; +} + +// Writes one archive entry under `dest`. Entries whose names would escape +// `dest` are skipped, as the Python zipfile sanitiser also refuses to write +// outside the target directory. +Result extractEntry(zip_t* archive, zip_uint64_t index, + const QDir& dest) { + zip_stat_t stat; + zip_stat_init(&stat); + if (zip_stat_index(archive, index, 0, &stat) != 0 || + !(stat.valid & ZIP_STAT_NAME)) { + return std::unexpected( + Error{QStringLiteral("Unreadable community rules archive entry")}); + } + + const QString name = QString::fromUtf8(stat.name); + const QString relative = QDir::cleanPath(name); + const bool escapes = relative == QLatin1String("..") || + relative.startsWith(QLatin1String("../")) || + QDir::isAbsolutePath(relative); + if (escapes || relative == QLatin1String(".") || relative.isEmpty()) { + return {}; + } + + if (name.endsWith(u'/')) { + if (!dest.mkpath(relative)) { + return std::unexpected(Error{ + QStringLiteral("Cannot create %1").arg(dest.filePath(relative))}); + } + return {}; + } + + if (!dest.mkpath(QFileInfo(relative).path())) { + return std::unexpected( + Error{QStringLiteral("Cannot create directory for %1").arg(relative)}); + } + zip_file_t* entry = zip_fopen_index(archive, index, 0); + if (!entry) { + return std::unexpected( + Error{QStringLiteral("Cannot read %1 from archive").arg(name)}); + } + auto closeEntry = qScopeGuard([entry] { zip_fclose(entry); }); + + const QString target = dest.filePath(relative); + QFile out(target); + if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return std::unexpected( + Error{QStringLiteral("Cannot write %1").arg(target)}); + } + + QByteArray buffer(kExtractChunkBytes, Qt::Uninitialized); + for (;;) { + const zip_int64_t read = zip_fread( + entry, buffer.data(), static_cast(buffer.size())); + if (read < 0) { + return std::unexpected( + Error{QStringLiteral("Corrupt archive entry %1").arg(name)}); + } + if (read == 0) { + break; + } + if (out.write(buffer.constData(), read) != read) { + return std::unexpected( + Error{QStringLiteral("Cannot write %1").arg(target)}); + } + } + return {}; +} + +Result extractZip(const QString& zipPath, const QString& destDir) { + int errorCode = 0; + zip_t* archive = + zip_open(QFile::encodeName(zipPath).constData(), ZIP_RDONLY, &errorCode); + if (!archive) { + return std::unexpected( + Error{QStringLiteral("Cannot open %1 (libzip error %2)") + .arg(zipPath) + .arg(errorCode)}); + } + auto closeArchive = qScopeGuard([archive] { zip_close(archive); }); + + if (!QDir().mkpath(destDir)) { + return std::unexpected( + Error{QStringLiteral("Cannot create %1").arg(destDir)}); + } + const QDir dest(destDir); + const zip_int64_t count = zip_get_num_entries(archive, 0); + for (zip_int64_t i = 0; i < count; ++i) { + if (auto extracted = + extractEntry(archive, static_cast(i), dest); + !extracted) { + return extracted; + } + } + return {}; +} + +} // namespace + +CommunityRulesService::CommunityRulesService(const ConfigService& configService, + QNetworkAccessManager& network) + : m_cacheDir(configService.directory()), + m_jsonPath(QDir(m_cacheDir).filePath(kRulesFilename)), + m_network(network) {} + +QCoro::Task> CommunityRulesService::ensureRules( + LoadingState& loading, bool force) { + if (QFileInfo::exists(m_jsonPath) && !force) { + co_return m_jsonPath; + } + + auto result = co_await downloadRules(loading); + if (!result) { + spdlog::warn("Failed to download community rules: {}", + result.error().message.toStdString()); + co_return std::nullopt; + } + co_return *result; +} + +QCoro::Task>> +CommunityRulesService::downloadRules(LoadingState& loading) { + if (!QDir().mkpath(m_cacheDir)) { + co_return std::unexpected( + Error{QStringLiteral("Cannot create %1").arg(m_cacheDir)}); + } + const QDir cache(m_cacheDir); + const QString zipPath = cache.filePath(QStringLiteral("community_rules.zip")); + const QString extractDir = + cache.filePath(QStringLiteral("community_rules_extracted")); + + QNetworkRequest request{QUrl(kRulesUrl)}; + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, + QNetworkRequest::NoLessSafeRedirectPolicy); + + // The HEAD request only sizes the progress bar; a missing Content-Length + // falls back to 100 steps, which is what the original did. + request.setTransferTimeout(kHeadTransferTimeoutMs); + QNetworkReply* head = m_network.head(request); + auto releaseHead = qScopeGuard([head] { head->deleteLater(); }); + co_await qCoro(head).waitForFinished(); + if (head->error() != QNetworkReply::NoError) { + co_return std::unexpected(networkError(*head)); + } + const qint64 contentLength = + head->header(QNetworkRequest::ContentLengthHeader).toLongLong(); + const int totalSize = + contentLength > 0 ? static_cast(std::min( + contentLength, std::numeric_limits::max())) + : 100; + + request.setTransferTimeout(kGetTransferTimeoutMs); + QNetworkReply* get = m_network.get(request); + auto releaseGet = qScopeGuard([get] { get->deleteLater(); }); + + QFile zipFile(zipPath); + if (!zipFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + co_return std::unexpected( + Error{QStringLiteral("Cannot write %1").arg(zipPath)}); + } + { + auto download = loading.task( + QStringLiteral("Downloading community rules..."), totalSize); + QObject::connect(get, &QNetworkReply::readyRead, get, + [get, &zipFile, &loading] { + const QByteArray chunk = get->readAll(); + zipFile.write(chunk); + loading.step(static_cast(chunk.size())); + }); + co_await qCoro(get).waitForFinished(); + if (get->error() != QNetworkReply::NoError) { + co_return std::unexpected(networkError(*get)); + } + zipFile.write(get->readAll()); + if (zipFile.error() != QFileDevice::NoError) { + co_return std::unexpected(Error{zipFile.errorString()}); + } + zipFile.close(); + } + + { + auto extract = loading.task(QStringLiteral("Extracting..."), 100); + if (auto extracted = extractZip(zipPath, extractDir); !extracted) { + co_return std::unexpected(extracted.error()); + } + loading.step(50); + + QDirIterator rules(extractDir, {kRulesFilename}, QDir::Files, + QDirIterator::Subdirectories); + if (!rules.hasNext()) { + // An archive without the rules file is not an error: there is simply + // nothing to cache. + co_return std::optional{}; + } + if (auto copied = copyFileReplacing(rules.next(), m_jsonPath); !copied) { + co_return std::unexpected(copied.error()); + } + loading.step(50); + } + co_return std::optional(m_jsonPath); +} + +Result CommunityRulesService::loadRules() const { + return loadCommunityRules(m_jsonPath); +} + +} // namespace px::modrim::sort diff --git a/src/core/sort/community_service.hpp b/src/core/sort/community_service.hpp new file mode 100644 index 0000000..fd46cfe --- /dev/null +++ b/src/core/sort/community_service.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include + +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/loading.hpp" +#include "core/sort/models.hpp" + +class QNetworkAccessManager; + +namespace px::modrim::sort { + +// Keeps the community rules file in the config directory, downloading the +// Community-Rules-Database archive when the file is missing. +class CommunityRulesService { + public: + // `network` is not owned and must outlive the service. + CommunityRulesService(const ConfigService& configService, + QNetworkAccessManager& network); + + // Returns the rules file path, downloading first when it is missing or + // `force` is set. nullopt when the download or extraction failed, or the + // archive has no rules file; the failure is logged, not returned. + QCoro::Task> ensureRules(LoadingState& loading, + bool force = false); + + Result loadRules() const; + + private: + QCoro::Task>> downloadRules( + LoadingState& loading); + + QString m_cacheDir; + QString m_jsonPath; + QNetworkAccessManager& m_network; +}; + +} // namespace px::modrim::sort diff --git a/src/core/sort/config.cpp b/src/core/sort/config.cpp new file mode 100644 index 0000000..8335a9a --- /dev/null +++ b/src/core/sort/config.cpp @@ -0,0 +1,42 @@ +#include "core/sort/config.hpp" + +#include + +namespace px::modrim::sort { + +TierConfig TierConfig::defaults() { + return TierConfig{ + .tier_0 = + { + PackageId(QStringLiteral("zetrith.prepatcher")), + PackageId(QStringLiteral("brrainz.harmony")), + PackageId(QStringLiteral("brrainz.visualexceptions")), + PackageId(QStringLiteral("ludeon.rimworld")), + PackageId(QStringLiteral("ludeon.rimworld.royalty")), + PackageId(QStringLiteral("ludeon.rimworld.ideology")), + PackageId(QStringLiteral("ludeon.rimworld.biotech")), + PackageId(QStringLiteral("ludeon.rimworld.anomaly")), + PackageId(QStringLiteral("ludeon.rimworld.odyssey")), + }, + .tier_1 = + { + PackageId(QStringLiteral("adaptive.storage.framework")), + PackageId(QStringLiteral("aoba.framework")), + PackageId(QStringLiteral("aoba.exosuit.framework")), + PackageId(QStringLiteral("ebsg.framework")), + PackageId(QStringLiteral("imranfish.xmlextensions")), + PackageId(QStringLiteral("thesepeople.ritualattachableoutcomes")), + PackageId(QStringLiteral("ohno.asf.ab.local")), + PackageId( + QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")), + PackageId(QStringLiteral("owlchemist.cherrypicker")), + PackageId(QStringLiteral("redmattis.betterprerequisites")), + PackageId(QStringLiteral("smashphil.vehicleframework")), + PackageId(QStringLiteral("unlimitedhugs.hugslib")), + PackageId(QStringLiteral("vanillaexpanded.backgrounds")), + }, + .tier_3 = {}, + }; +} + +} // namespace px::modrim::sort diff --git a/src/core/sort/config.hpp b/src/core/sort/config.hpp new file mode 100644 index 0000000..a74e9a7 --- /dev/null +++ b/src/core/sort/config.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include "core/models/metadata/structures.hpp" + +namespace px::modrim::sort { + +using PackageId = CaseInsensitiveStr; + +enum class Tier : int { kTier0 = 0, kTier1 = 1, kTier2 = 2, kTier3 = 3 }; + +// Serialised by glaze into config.json: member names are the JSON keys. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct TierConfig { + std::vector tier_0; + std::vector tier_1; + std::vector tier_3; + + static TierConfig defaults(); + + bool operator==(const TierConfig&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct SortSettings { + bool use_alternative_package_ids = true; + bool check_missing_dependencies = true; + bool use_community_rules = true; + TierConfig tier_config = TierConfig::defaults(); + + bool operator==(const SortSettings&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +} // namespace px::modrim::sort diff --git a/src/core/sort/models.hpp b/src/core/sort/models.hpp new file mode 100644 index 0000000..2ba3d69 --- /dev/null +++ b/src/core/sort/models.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" + +namespace px::modrim::sort { + +// Load-order rule from the community database for one package id. +struct CommunityRule { + PackageId packageId; + CaseInsensitiveSet loadAfter; + CaseInsensitiveSet loadBefore; + bool loadFirst = false; + bool loadLast = false; + CaseInsensitiveSet incompatibleWith; + + bool operator==(const CommunityRule&) const = default; +}; + +using CommunityRules = QHash; + +// Adjacency map over package ids: a node's dependencies or reverse +// dependencies. +using PackageGraph = QHash>; + +} // namespace px::modrim::sort diff --git a/src/core/sort/tiers.cpp b/src/core/sort/tiers.cpp new file mode 100644 index 0000000..7410c57 --- /dev/null +++ b/src/core/sort/tiers.cpp @@ -0,0 +1,173 @@ +#include "core/sort/tiers.hpp" + +#include +#include + +namespace px::modrim::sort { + +namespace { + +// Every node reachable from `start` by at least one edge. `start` is included +// only when a cycle leads back to it. +QSet reachable(const PackageId& start, const PackageGraph& graph) { + QSet result; + QSet visited{start}; + std::vector stack{start}; + while (!stack.empty()) { + const PackageId pid = stack.back(); + stack.pop_back(); + for (const auto& next : graph.value(pid)) { + result.insert(next); + if (!visited.contains(next)) { + visited.insert(next); + stack.push_back(next); + } + } + } + return result; +} + +// Adds `ids` to `tiers` as `tier`, skipping ids that are not canonical (keys of +// `tiers`). +void assignPresent(QHash& tiers, const QSet& ids, + Tier tier) { + for (const auto& pid : ids) { + if (auto it = tiers.find(pid); it != tiers.end()) { + *it = tier; + } + } +} + +} // namespace + +QHash assignTiers(const QSet& canonicalIds, + const PackageGraph& deps, + const PackageGraph& revDeps, + const QSet& loadFirst, + const QSet& loadLast, + const TierConfig& tierConfig) { + QHash tiers; + for (const auto& pid : canonicalIds) { + tiers.insert(pid, Tier::kTier2); + } + + // Tier 3 is the bottom of the load order, so dependents of late mods also + // sink there. + QSet tier3Set = loadLast; + for (const auto& pid : loadLast) { + tier3Set |= reachable(pid, revDeps); + } + for (const auto& pid : tierConfig.tier_3) { + if (canonicalIds.contains(pid)) { + tier3Set.insert(pid); + tier3Set |= reachable(pid, revDeps); + } + } + assignPresent(tiers, tier3Set, Tier::kTier3); + + // Tier 0 is assigned after tier 3, so a configured tier-0 mod wins over a + // tier-3 mark. + QSet tier0Set; + for (const auto& pid : tierConfig.tier_0) { + if (canonicalIds.contains(pid)) { + tier0Set.insert(pid); + tier0Set |= reachable(pid, deps); + } + } + assignPresent(tiers, tier0Set, Tier::kTier0); + + // Only tier-2 mods move up: a tier-0 or tier-3 mark is never overridden here. + QSet tier1Set; + for (const auto& pid : tierConfig.tier_1) { + if (canonicalIds.contains(pid)) { + tier1Set.insert(pid); + tier1Set |= reachable(pid, deps); + } + } + for (const auto& pid : loadFirst) { + if (canonicalIds.contains(pid)) { + tier1Set.insert(pid); + tier1Set |= reachable(pid, deps); + } + } + for (const auto& pid : tier1Set) { + if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::kTier2) { + *it = Tier::kTier1; + } + } + + return tiers; +} + +QSet getDepsRecursive(const PackageId& start, + const PackageGraph& deps) { + return reachable(start, deps); +} + +QSet getReverseDepsRecursive(const PackageId& start, + const PackageGraph& revDeps) { + return reachable(start, revDeps); +} + +std::optional> findCycle(const QSet& nodes, + const PackageGraph& deps) { + // One frame per node on the DFS stack; `next` indexes the node's outgoing + // edges. + struct Frame { + PackageId pid; + QList edges; + qsizetype next = 0; + }; + + QSet visited; + QSet recStack; + QHash parent; + + for (const auto& root : nodes) { + if (visited.contains(root)) { + continue; + } + + parent.insert(root, root); + visited.insert(root); + recStack.insert(root); + std::vector stack{Frame{root, deps.value(root).values()}}; + while (!stack.empty()) { + Frame& top = stack.back(); + if (top.next == top.edges.size()) { + recStack.remove(top.pid); + stack.pop_back(); + continue; + } + + const PackageId pid = top.pid; + const PackageId dep = top.edges[top.next++]; + if (!nodes.contains(dep)) { + continue; + } + if (!visited.contains(dep)) { + parent.insert(dep, pid); + visited.insert(dep); + recStack.insert(dep); + // `top` is invalid after this push. + stack.push_back(Frame{dep, deps.value(dep).values()}); + } else if (recStack.contains(dep)) { + // Walk parents from the current node back to `dep`, which is on the DFS + // path. + QList cycle{dep}; + PackageId current = pid; + while (current != dep) { + cycle.append(current); + current = parent.value(current); + } + cycle.append(dep); + std::ranges::reverse(cycle); + return cycle; + } + } + } + + return std::nullopt; +} + +} // namespace px::modrim::sort diff --git a/src/core/sort/tiers.hpp b/src/core/sort/tiers.hpp new file mode 100644 index 0000000..92c15c9 --- /dev/null +++ b/src/core/sort/tiers.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include +#include + +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" + +namespace px::modrim::sort { + +// Assigns each canonical id to a Tier from load order and dependency +// constraints. Ids outside `canonical_ids` are never returned. +QHash assignTiers(const QSet& canonicalIds, + const PackageGraph& deps, + const PackageGraph& revDeps, + const QSet& loadFirst, + const QSet& loadLast, + const TierConfig& tierConfig); + +// Transitive closure over `deps`, excluding `start` itself unless it is +// reachable from itself. +QSet getDepsRecursive(const PackageId& start, + const PackageGraph& deps); + +// Transitive closure over `rev_deps`, excluding `start` itself unless it is +// reachable from itself. +QSet getReverseDepsRecursive(const PackageId& start, + const PackageGraph& revDeps); + +// Returns one cycle among `nodes` as a closed path (first == last), or nullopt +// when the nodes form a DAG. Edges leaving `nodes` are ignored. +std::optional> findCycle(const QSet& nodes, + const PackageGraph& deps); + +} // namespace px::modrim::sort diff --git a/src/core/structures.hpp b/src/core/structures.hpp new file mode 100644 index 0000000..e0cf887 --- /dev/null +++ b/src/core/structures.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace px::modrim { + +struct CollectionStats { + std::int64_t total = 0; + std::int64_t active = 0; + std::int64_t inactive = 0; + std::int64_t errors = 0; +}; + +} // namespace px::modrim diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp new file mode 100644 index 0000000..2afd4a4 --- /dev/null +++ b/src/core/support/report.cpp @@ -0,0 +1,395 @@ +#include "core/support/report.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/models/metadata/structures.hpp" +#include "core/version.hpp" + +#if defined(Q_OS_UNIX) +#include +#include +#endif + +namespace px::modrim::support { + +namespace { + +constexpr qint64 kMib = 1024 * 1024; +constexpr qint64 kKib = 1024; +constexpr qint64 kMinReadWindow = 10 * kMib; +// Upload responses are shown back to the user, so only a short prefix is +// echoed. +constexpr qsizetype kMaxEchoedBody = 100; + +// A path component may not run into adjacent name characters, so +// "ludeon.rimworld" is never mistaken for a path that contains the username +// "rimworld". +constexpr QLatin1StringView kComponentStart{R"re((?pw_name); + } +#endif + return QString(); +} + +QString formatSize(qint64 bytes) { + if (bytes >= kMib) { + return QString::number( + static_cast(bytes) / static_cast(kMib), 'f', 1) + + QStringLiteral(" MiB"); + } + if (bytes >= kKib) { + return QString::number( + static_cast(bytes) / static_cast(kKib), 'f', 1) + + QStringLiteral(" KiB"); + } + return QString::number(bytes) + QStringLiteral(" B"); +} + +// Splits the way Python's str.splitlines() does: several characters end a line, +// and a trailing terminator does not start an empty line. +QStringList splitLines(const QString& text) { + QStringList lines; + qsizetype start = 0; + for (qsizetype i = 0; i < text.size(); ++i) { + const char16_t c = text[i].unicode(); + const bool boundary = c == u'\n' || c == u'\r' || c == u'\v' || + c == u'\f' || c == 0x1c || c == 0x1d || c == 0x1e || + c == 0x85 || c == 0x2028 || c == 0x2029; + if (!boundary) { + continue; + } + lines.append(text.mid(start, i - start)); + if (c == u'\r' && i + 1 < text.size() && text[i + 1] == u'\n') { + ++i; + } + start = i + 1; + } + if (start < text.size()) { + lines.append(text.mid(start)); + } + return lines; +} + +// loguru writes each record as a JSON object whose "text" holds the formatted +// line. Lines that are not such records pass through unchanged. +QString logLineText(const QString& line) { + const QString trimmed = line.trimmed(); + if (!trimmed.startsWith(u'{') || !trimmed.endsWith(u'}')) { + return line; + } + QJsonParseError error; + const QJsonDocument document = + QJsonDocument::fromJson(trimmed.toUtf8(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + return line; + } + const QJsonValue text = document.object().value(QStringLiteral("text")); + if (!text.isString()) { + return line; + } + QString result = text.toString(); + while (result.endsWith(u'\r') || result.endsWith(u'\n')) { + result.chop(1); + } + return result; +} + +QString parseLogText(const QString& rawText) { + QStringList lines = splitLines(rawText); + for (QString& line : lines) { + line = logLineText(line); + } + QString result = lines.join(QLatin1Char('\n')); + if (rawText.endsWith(u'\n') && !result.isEmpty()) { + result += u'\n'; + } + return result; +} + +// Drops the partial line at the start of a tail slice. +QByteArray afterFirstNewline(const QByteArray& data) { + const qsizetype newline = data.indexOf('\n'); + return newline < 0 ? data : data.mid(newline + 1); +} + +Result readRawTail(QFile& file, qint64 size, qint64 window) { + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected(Error{file.errorString()}); + } + if (size <= window) { + return file.readAll(); + } + file.seek(size - window); + return afterFirstNewline(file.read(window)); +} + +// Python's repr() of a string, for quoting part of a server response in an +// error message. +QString pyRepr(const QString& text) { + const QChar quote = + text.contains(u'\'') && !text.contains(u'"') ? QChar(u'"') : QChar(u'\''); + QString out(quote); + for (const QChar c : text) { + switch (c.unicode()) { + case u'\\': + out += QStringLiteral("\\\\"); + break; + case u'\n': + out += QStringLiteral("\\n"); + break; + case u'\r': + out += QStringLiteral("\\r"); + break; + case u'\t': + out += QStringLiteral("\\t"); + break; + default: + if (c == quote) { + out += u'\\'; + } + out += c; + } + } + out += quote; + return out; +} + +} // namespace + +QString redact(const QString& text, const std::optional& homeDir, + const std::optional& username) { + QString result = text; + + QString home = homeDir ? *homeDir : QDir::homePath(); + while (home.endsWith(u'/') || home.endsWith(u'\\')) { + home.chop(1); + } + if (!home.isEmpty()) { + // The same directory may appear with either separator, and with doubled + // backslashes when it was embedded in a repr(). + const QString backslashed = QString(home).replace(u'/', u'\\'); + QSet variants{ + home, QString(home).replace(u'\\', u'/'), backslashed, + QString(backslashed).replace(u'\\', QStringLiteral("\\\\"))}; + QStringList ordered(variants.begin(), variants.end()); + // Longest first, so a variant that is a prefix of another cannot shadow it. + std::ranges::stable_sort(ordered, {}, + [](const QString& s) { return s.size(); }); + std::ranges::reverse(ordered); + + QStringList alternatives; + for (const QString& variant : ordered) { + alternatives.append(QRegularExpression::escape(variant)); + } + const QRegularExpression homePattern = unicodeRegex( + QString(kComponentStart) + QStringLiteral("(?:") + + alternatives.join(u'|') + QStringLiteral(")") + QString(kComponentEnd)); + result.replace(homePattern, QStringLiteral("~")); + } + + // Only in path contexts: a bare word match would corrupt ids such as + // ludeon.rimworld. + const QString user = username ? *username : currentUsername(); + if (!user.isEmpty()) { + const QRegularExpression userPattern = unicodeRegex( + QStringLiteral("(") + QString(kUserDirPrefix) + QStringLiteral(")") + + QRegularExpression::escape(user) + QString(kComponentEnd), + QRegularExpression::CaseInsensitiveOption); + result.replace(userPattern, QStringLiteral("\\1")); + } + return result; +} + +QString readLogTail(const std::optional& logPath, qint64 maxBytes) { + const QString path = + logPath ? *logPath + : QDir(configDir()).filePath(QStringLiteral("logs/pxmodrim.log")); + const QFileInfo info(path); + if (!info.isFile()) { + return QStringLiteral(""); + } + const qint64 size = info.size(); + if (size == 0) { + return QStringLiteral(""); + } + + // JSON records shrink once unpacked, so read a wider raw window than the byte + // budget. + const qint64 readWindow = std::max(maxBytes * 5, kMinReadWindow); + QFile file(path); + auto raw = readRawTail(file, size, readWindow); + if (!raw) { + return QStringLiteral("") + .arg(raw.error().message); + } + + const QString plain = parseLogText(QString::fromUtf8(*raw)); + const QByteArray encoded = plain.toUtf8(); + if (encoded.size() <= maxBytes) { + return plain; + } + + const QByteArray tail = afterFirstNewline(encoded.right(maxBytes)); + return QStringLiteral("[... truncated, showing last %1 of %2 ...]\n") + .arg(formatSize(tail.size()), formatSize(encoded.size())) + + QString::fromUtf8(tail); +} + +QString buildReport(const ReportSource& source, + const std::optional& logPath, + const std::optional& homeDir, + const std::optional& username, + qint64 maxLogBytes) { + QStringList lines; + + lines << QStringLiteral("=== PxModRim System Report ==="); + lines << QStringLiteral("App Version: ") + appVersion(); + lines << QStringLiteral("Qt Version: ") + QString::fromLatin1(qVersion()); + lines << QStringLiteral("OS: %1 %2 (%3)") + .arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), + QSysInfo::currentCpuArchitecture()); + lines << QString(); + + const PathConfig& paths = source.paths; + lines << QStringLiteral("=== RimWorld Configuration ==="); + lines << QStringLiteral("Game Version: ") + source.gameVersion; + const auto orNone = [](const std::string& value) { + return value.empty() ? QStringLiteral("") + : QString::fromStdString(value); + }; + lines << QStringLiteral("Game Path: ") + orNone(paths.game); + lines << QStringLiteral("Config Folder: ") + orNone(paths.config_folder); + lines << QStringLiteral("Local Mods Path: ") + orNone(paths.local); + lines << QStringLiteral("Workshop Mods Path: ") + orNone(paths.workshop); + lines << QString(); + + lines << QStringLiteral("=== Mod Collection ==="); + lines << QStringLiteral("Total Mods: %1").arg(source.allMods.size()); + lines << QStringLiteral("Active Mods: %1").arg(source.activeUuids.size()); + lines << QString(); + + lines << QStringLiteral("=== Active Mods (Load Order) ==="); + if (source.activeUuids.isEmpty()) { + lines << QStringLiteral(" "); + } + for (qsizetype i = 0; i < source.activeUuids.size(); ++i) { + const QString& uuid = source.activeUuids[i]; + QString modName = QStringLiteral("Unknown Mod"); + QString pid = uuid; + if (const auto found = source.allMods.constFind(uuid); + found != source.allMods.cend()) { + modName = (*found)->name; + const auto* about = dynamic_cast(found->get()); + pid = about ? about->packageId.str() : QStringLiteral("Unknown"); + } + lines << QStringLiteral("%1. %2 (%3)") + .arg(QString::number(i + 1).rightJustified(3), modName, pid); + } + lines << QString(); + + lines << QStringLiteral("=== Application Log ==="); + lines << readLogTail(logPath, maxLogBytes); + + return redact(lines.join(QLatin1Char('\n')), homeDir, username); +} + +PasteUploader::PasteUploader(QNetworkAccessManager& network, QString endpoint, + std::chrono::milliseconds timeout) + : m_network(network), m_endpoint(std::move(endpoint)), m_timeout(timeout) {} + +QCoro::Task> PasteUploader::upload(QString text) { + if (text.trimmed().isEmpty()) { + co_return std::unexpected(Error{QStringLiteral("Report text is empty")}); + } + + QNetworkRequest request{QUrl(m_endpoint)}; + request.setRawHeader("User-Agent", + (QStringLiteral("PxModRim/") + appVersion()).toUtf8()); + request.setHeader(QNetworkRequest::ContentTypeHeader, + QStringLiteral("text/plain; charset=utf-8")); + request.setTransferTimeout(static_cast(m_timeout.count())); + + QNetworkReply* reply = m_network.post(request, text.toUtf8()); + const auto releaseReply = qScopeGuard([reply] { reply->deleteLater(); }); + co_await qCoro(reply).waitForFinished(); + + if (reply->error() == QNetworkReply::TimeoutError) { + co_return std::unexpected( + Error{QStringLiteral("Upload failed: request timed out after %1s") + .arg(QString::number(m_timeout.count() / 1000.0))}); + } + // No HTTP status means the request never got a response. + const int status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status == 0) { + co_return std::unexpected( + Error{QStringLiteral("Upload failed: network error (%1)") + .arg(reply->errorString())}); + } + if (status < 200 || status >= 300) { + const QString reason = QString::fromUtf8( + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute) + .toByteArray()); + co_return std::unexpected(Error{ + reason.isEmpty() ? QStringLiteral("Upload failed: HTTP %1").arg(status) + : QStringLiteral("Upload failed: HTTP %1 %2") + .arg(status) + .arg(reason)}); + } + if (reply->error() != QNetworkReply::NoError) { + co_return std::unexpected( + Error{QStringLiteral("Upload failed: network error (%1)") + .arg(reply->errorString())}); + } + + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (!body.startsWith(QLatin1StringView("http://")) && + !body.startsWith(QLatin1StringView("https://"))) { + co_return std::unexpected(Error{ + QStringLiteral("Upload failed: server did not return a valid URL: %1") + .arg(pyRepr(body.left(kMaxEchoedBody)))}); + } + co_return body; +} + +} // namespace px::modrim::support diff --git a/src/core/support/report.hpp b/src/core/support/report.hpp new file mode 100644 index 0000000..b1049b4 --- /dev/null +++ b/src/core/support/report.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" + +class QNetworkAccessManager; + +namespace px::modrim::support { + +inline constexpr qint64 kMaxLogBytes = 2 * 1024 * 1024; +inline constexpr QLatin1StringView kDefaultUploadEndpoint{"https://paste.rs/"}; + +// The app state a diagnostic report describes. Copied out of the live +// application so that building a report never touches it. +struct ReportSource { + QString gameVersion = QStringLiteral("Unknown"); + PathConfig paths; + // Keyed by uuid, as the mod registry is. + QHash allMods; + QStringList activeUuids; +}; + +// Replaces the home directory with "~" and user-directory usernames with +// "". The home directory and username default to the current user's when +// not given. +QString redact(const QString& text, + const std::optional& homeDir = std::nullopt, + const std::optional& username = std::nullopt); + +// The last `max_bytes` of the log as plain text, with loguru JSON records +// unpacked. Defaults to the app's own log under the config directory. +QString readLogTail(const std::optional& logPath = std::nullopt, + qint64 maxBytes = kMaxLogBytes); + +// Plain-text system and log report, with the user's details redacted. +QString buildReport(const ReportSource& source, + const std::optional& logPath = std::nullopt, + const std::optional& homeDir = std::nullopt, + const std::optional& username = std::nullopt, + qint64 maxLogBytes = kMaxLogBytes); + +// Posts plain-text reports to a paste service. Not owned; `network` must +// outlive the uploader. +class PasteUploader { + public: + PasteUploader(QNetworkAccessManager& network, + QString endpoint = QString(kDefaultUploadEndpoint), + std::chrono::milliseconds timeout = std::chrono::seconds(30)); + + // Resolves to the URL the service returned, or to a user-facing error + // message. + QCoro::Task> upload(QString text); + + private: + QNetworkAccessManager& m_network; + QString m_endpoint; + std::chrono::milliseconds m_timeout; +}; + +} // namespace px::modrim::support diff --git a/src/core/toposort.hpp b/src/core/toposort.hpp new file mode 100644 index 0000000..0bd2648 --- /dev/null +++ b/src/core/toposort.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "core/error.hpp" + +namespace px::modrim { + +// Kahn's algorithm, layered the way the `toposort` package lays levels: level 0 +// holds nodes without dependencies, and each later level holds nodes whose +// dependencies all sit in earlier levels. Names within a level are ascending, +// so the result is deterministic. Self-dependencies and dependencies that are +// not keys of `graph` are ignored. +inline Result> toposortLevels( + const std::map>& graph) { + std::map> pending; + for (const auto& [node, deps] : graph) { + QSet edges; + for (const auto& dep : deps) { + if (dep != node && graph.contains(dep)) { + edges.insert(dep); + } + } + pending.emplace(node, std::move(edges)); + } + + std::vector levels; + while (!pending.empty()) { + QStringList level; + for (const auto& [node, deps] : pending) { + if (deps.isEmpty()) { + level.append(node); + } + } + if (level.isEmpty()) { + QStringList stuck; + for (const auto& [node, deps] : pending) { + stuck.append(node); + } + return std::unexpected(Error{ + QStringLiteral("Circular dependencies exist among these plugins: %1") + .arg(stuck.join(QStringLiteral(", ")))}); + } + for (const auto& node : level) { + pending.erase(node); + } + for (auto& [node, deps] : pending) { + for (const auto& done : level) { + deps.remove(done); + } + } + levels.push_back(std::move(level)); + } + return levels; +} + +} // namespace px::modrim diff --git a/src/core/utils.cpp b/src/core/utils.cpp new file mode 100644 index 0000000..d950663 --- /dev/null +++ b/src/core/utils.cpp @@ -0,0 +1,52 @@ +#include "core/utils.hpp" + +#include +#include + +namespace px::modrim { + +std::optional findAboutXml(const QString& modPath) { + if (!QFileInfo(modPath).isDir()) { + return std::nullopt; + } + const QDir modDir(modPath); + const QString candidate = modDir.filePath(QStringLiteral("About/About.xml")); + if (QFileInfo(candidate).isFile()) { + return candidate; + } + + // Only the first directory whose name matches "about" (any case) is searched, + // as in the legacy implementation. + const QFileInfoList entries = modDir.entryInfoList( + QDir::Dirs | QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& entry : entries) { + if (entry.fileName().toLower() != QStringLiteral("about") || + !entry.isDir()) { + continue; + } + const QDir aboutDir(entry.absoluteFilePath()); + const QFileInfoList children = aboutDir.entryInfoList( + QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot); + for (const QFileInfo& child : children) { + if (child.fileName().toLower() == QStringLiteral("about.xml")) { + return child.absoluteFilePath(); + } + } + break; + } + return std::nullopt; +} + +bool isAsciiDigits(QStringView text) { + if (text.isEmpty()) { + return false; + } + for (const QChar ch : text) { + if (ch.unicode() < u'0' || ch.unicode() > u'9') { + return false; + } + } + return true; +} + +} // namespace px::modrim diff --git a/src/core/utils.hpp b/src/core/utils.hpp new file mode 100644 index 0000000..40321e5 --- /dev/null +++ b/src/core/utils.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include + +namespace px::modrim { + +// Case-insensitive search for About/About.xml inside a mod directory. +std::optional findAboutXml(const QString& modPath); + +// True for a non-empty string made only of ASCII digits (Python's str.isdigit +// for ASCII input). +bool isAsciiDigits(QStringView text); + +} // namespace px::modrim diff --git a/src/core/version.cpp b/src/core/version.cpp new file mode 100644 index 0000000..1d31b44 --- /dev/null +++ b/src/core/version.cpp @@ -0,0 +1,19 @@ +#include "core/version.hpp" + +#include + +#include + +namespace px::modrim { + +QString appVersion() { return QStringLiteral("0.3.1"); } + +QCoro::Task delayedVersion(int ms) { + QTimer timer; + timer.setSingleShot(true); + timer.start(ms); + co_await timer; + co_return appVersion(); +} + +} // namespace px::modrim diff --git a/src/core/version.hpp b/src/core/version.hpp new file mode 100644 index 0000000..5cd9a05 --- /dev/null +++ b/src/core/version.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include + +namespace px::modrim { + +QString appVersion(); + +// Smoke coroutine: proves QCoro runs on the Qt event loop. +QCoro::Task delayedVersion(int ms); + +} // namespace px::modrim diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp new file mode 100644 index 0000000..1ac86c1 --- /dev/null +++ b/src/core/workshop/catalog.cpp @@ -0,0 +1,407 @@ +#include "core/workshop/catalog.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/context.hpp" +#include "core/mod_service.hpp" + +namespace px::modrim::workshop { + +namespace { + +// Memory keeps the newest responses; the disk keeps the newest cache files. +constexpr qsizetype kCacheLimit = 200; +constexpr qsizetype kDiskLimit = 300; + +QStringList toQstrings(const std::vector& values) { + QStringList out; + out.reserve(static_cast(values.size())); + for (const std::string& value : values) { + out.append(QString::fromStdString(value)); + } + return out; +} + +const std::string& titleOf(const CatalogEntry& entry) { + return std::visit( + [](const auto& item) -> const std::string& { return item.title; }, entry); +} + +} // namespace + +namespace detail { + +Result writeCached(const QString& directory, const QString& path, + const std::string& text) { + if (auto saved = writeFileAtomically(path, text); !saved) { + return saved; + } + QFileInfoList files = + QDir(directory).entryInfoList({QStringLiteral("*.json")}, QDir::Files); + std::ranges::sort(files, [](const QFileInfo& a, const QFileInfo& b) { + return a.lastModified() < b.lastModified(); + }); + for (qsizetype i = 0; i + kDiskLimit < files.size(); ++i) { + QFile::remove(files[i].absoluteFilePath()); + } + return {}; +} + +} // namespace detail + +WorkshopCatalog::WorkshopCatalog() + : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), + m_settings(nullptr, QStringLiteral("workshop_catalog")), + m_baseUrl( + normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), + m_favourites(nullptr) { + m_settings.changed.connect([this](const CatalogSettings& value) { + changeUrl(QString::fromStdString(value.url)); + }); +} + +void WorkshopCatalog::setup(CoreContext& ctx) { + m_context = &ctx; + m_installedMods = [&ctx] { return ctx.allMods().values(); }; + if (ctx.hasConfigService()) { + m_settings = PluginConfig( + &ctx.configService(), QStringLiteral("workshop_catalog"), + {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}); + m_settings.changed.connect([this](const CatalogSettings& value) { + changeUrl(QString::fromStdString(value.url)); + }); + changeUrl(QString::fromStdString(m_settings.value().url)); + m_cacheDir = + ctx.configService().directory() + QStringLiteral("/workshop-cache"); + m_favourites = FavouriteCollections(&ctx.configService()); + } + bindDownloads(ctx.downloads()); + m_modsChangedId = ctx.modService().modsChanged.connect( + [this](const std::monostate&) { invalidateInstalled(); }); + m_configChangedId = ctx.configChanged().connect( + [this](const AppConfig&) { invalidateInstalled(); }); +} + +WorkshopCatalog::WorkshopCatalog(ConfigService* config, + DownloadManager& downloads, + InstalledModsProvider installedMods) + : Plugin(QStringLiteral("workshop_catalog"), {QStringLiteral("downloads")}), + m_settings( + config, QStringLiteral("workshop_catalog"), + {{QStringLiteral("workshop_catalog_url"), QStringLiteral("url")}}), + m_baseUrl( + normalizeBaseUrl(QString::fromStdString(m_settings.value().url))), + m_favourites(config), + m_downloads(&downloads), + m_installedMods(std::move(installedMods)) { + if (config) { + m_cacheDir = config->directory() + QStringLiteral("/workshop-cache"); + } + m_settings.changed.connect([this](const CatalogSettings& settings) { + changeUrl(QString::fromStdString(settings.url)); + }); + bindDownloads(downloads); +} + +void WorkshopCatalog::bindDownloads(DownloadManager& downloads) { + m_downloads = &downloads; + m_downloadFinishedId = m_downloads->downloadFinished.connect( + [this](const DownloadResult&) { invalidateInstalled(); }); + m_busyChangedId = m_downloads->busyChanged.connect([this](bool busy) { + if (!busy) { + resumeIdleWaiters(); + } + }); +} + +void WorkshopCatalog::disconnectEvents() { + if (m_downloads) { + m_downloads->downloadFinished.disconnect(m_downloadFinishedId); + m_downloads->busyChanged.disconnect(m_busyChangedId); + m_downloadFinishedId = 0; + m_busyChangedId = 0; + } + if (m_context) { + m_context->modService().modsChanged.disconnect(m_modsChangedId); + m_context->configChanged().disconnect(m_configChangedId); + m_context = nullptr; + } +} + +WorkshopCatalog::~WorkshopCatalog() { + *m_alive = false; + disconnectEvents(); + resumeIdleWaiters(); +} + +QCoro::Task WorkshopCatalog::shutdown() { + m_stopping = true; + disconnectEvents(); + cancelQueue(); + auto runner = std::move(m_runner); + m_runner.reset(); + if (m_downloading) { + m_downloads->cancel(); + } + resumeIdleWaiters(); + for (const auto& client : m_clients) { + client->shutdown(); + } + if (runner) { + co_await *runner; + } +} + +void WorkshopCatalog::setBaseUrl(const QString& url) { + m_settings.update( + CatalogSettings{.url = normalizeBaseUrl(url).toStdString()}); +} + +void WorkshopCatalog::changeUrl(const QString& url) { + const QString normalized = normalizeBaseUrl(url); + if (normalized == m_baseUrl) { + return; + } + m_baseUrl = normalized; + m_client = nullptr; + catalogUrlChanged.notify(m_baseUrl); +} + +CatalogClient& WorkshopCatalog::client() { + if (!m_client) { + m_clients.push_back(std::make_unique(m_baseUrl)); + m_client = m_clients.back().get(); + } + return *m_client; +} + +QString WorkshopCatalog::cacheFile(const QString& key) const { + const QByteArray digest = + QCryptographicHash::hash(key.toUtf8(), QCryptographicHash::Sha256) + .toHex(); + return *m_cacheDir + QLatin1Char('/') + QString::fromLatin1(digest.left(32)) + + QStringLiteral(".json"); +} + +void WorkshopCatalog::remember(const QString& key, std::any value, + bool moveToEnd) { + if (moveToEnd || !m_responses.contains(key)) { + m_responseOrder.removeAll(key); + m_responseOrder.append(key); + } + m_responses.insert(key, std::move(value)); + while (m_responseOrder.size() > kCacheLimit) { + m_responses.remove(m_responseOrder.first()); + m_responseOrder.removeFirst(); + } +} + +QCoro::Task> WorkshopCatalog::discover() { + co_return co_await client().discover(); +} + +QCoro::Task>> WorkshopCatalog::mods( + CatalogQuery query) { + co_return co_await client().mods(std::move(query)); +} + +QCoro::Task>> +WorkshopCatalog::collections(CatalogQuery query) { + co_return co_await client().collections(std::move(query)); +} + +QCoro::Task>> WorkshopCatalog::mod( + QString id) { + co_return co_await client().mod(std::move(id)); +} + +QCoro::Task>> +WorkshopCatalog::collection(QString id) { + co_return co_await client().collection(std::move(id)); +} + +const WorkshopCatalog::InstalledIndex& WorkshopCatalog::installed() { + if (!m_installedIndex) { + InstalledIndex index; + for (const ListedModPtr& mod : m_installedMods()) { + if (auto id = mod->publishedFileId()) { + index[*id].append(mod); + } + } + m_installedIndex = std::move(index); + } + return *m_installedIndex; +} + +InstallState WorkshopCatalog::installStateOf( + const CatalogMod& mod, const InstalledIndex& index) const { + const QList local = index.value(QString::fromStdString(mod.id)); + if (local.isEmpty()) { + return InstallState::kMissing; + } + // Only copies downloaded through the manager have a sync time; a copy without + // one is never outdated. + std::optional newestSync; + for (const ListedModPtr& item : local) { + if (auto synced = m_downloads->lastSynced(*item)) { + newestSync = newestSync ? std::max(*newestSync, *synced) : *synced; + } + } + if (newestSync && mod.updated_at && + *newestSync < static_cast(*mod.updated_at)) { + return InstallState::kOutdated; + } + return InstallState::kInstalled; +} + +InstallState WorkshopCatalog::installState(const CatalogMod& mod) { + return installStateOf(mod, installed()); +} + +void WorkshopCatalog::invalidateInstalled() { + m_installedIndex.reset(); + installedChanged.notify(std::monostate{}); +} + +QCoro::Task>> +WorkshopCatalog::installedWithUpdates() { + QStringList ids = installed().keys(); + // The index is a hash, so sort the ids to request them in a stable order. + ids.sort(); + auto batch = co_await client().modsBatch(std::move(ids)); + if (!batch) { + co_return std::unexpected(batch.error()); + } + co_return std::move(batch->items); +} + +QCoro::Task> WorkshopCatalog::plan( + QStringList modIds, QStringList collectionIds) { + auto resolved = + co_await client().resolve(std::move(modIds), std::move(collectionIds)); + if (!resolved) { + co_return std::unexpected(resolved.error()); + } + const InstalledIndex& index = installed(); + DownloadPlan result; + for (const std::string& id : resolved->mod_ids) { + const auto found = resolved->items.find(id); + const auto* mod = found == resolved->items.end() + ? nullptr + : std::get_if(&found->second); + if (!mod) { + co_return std::unexpected(CatalogError{ + QStringLiteral( + "The Workshop catalog returned an invalid download plan."), + std::nullopt}); + } + const QString qid = QString::fromStdString(id); + if (installStateOf(*mod, index) == InstallState::kInstalled) { + result.already_current.append(qid); + } else { + result.to_download.append(qid); + } + } + for (const auto& [id, entry] : resolved->items) { + result.titles.insert(QString::fromStdString(id), + QString::fromStdString(titleOf(entry))); + } + result.unavailable_ids = toQstrings(resolved->unavailable_ids); + result.incomplete_collection_ids = + toQstrings(resolved->incomplete_collection_ids); + result.is_complete = resolved->is_complete; + co_return result; +} + +QCoro::Task> WorkshopCatalog::download( + DownloadPlan downloadPlan) { + co_return co_await m_downloads->downloadMods( + std::move(downloadPlan.to_download)); +} + +void WorkshopCatalog::enqueue(const DownloadPlan& downloadPlan) { + if (m_stopping) { + return; + } + const QSet active = m_downloads->activeIds(); + QStringList ids; + for (const QString& id : downloadPlan.to_download) { + if (!m_queued.contains(id) && !active.contains(id)) { + ids.append(id); + } + } + if (ids.isEmpty()) { + return; + } + m_queue.append(ids); + m_queued.unite(QSet(ids.begin(), ids.end())); + queueChanged.notify(std::monostate{}); + if (!m_draining) { + m_draining = true; + m_runner.emplace(drain()); + } +} + +void WorkshopCatalog::cancelQueue() { + m_queue.clear(); + m_queued.clear(); + queueChanged.notify(std::monostate{}); +} + +QCoro::Task WorkshopCatalog::drain() { + const auto alive = m_alive; + while (!m_queue.isEmpty()) { + const QStringList ids = m_queue.takeFirst(); + // A batch that waited for the running download stops the queue if that + // download was cancelled. + const bool waited = m_downloads->isDownloading(); + while (m_downloads->isDownloading()) { + co_await IdleWait{*this}; + if (!*alive) { + co_return; + } + if (m_stopping) { + m_draining = false; + co_return; + } + } + if (waited && m_downloads->cancelled()) { + cancelQueue(); + break; + } + + m_downloading = true; + auto result = co_await m_downloads->downloadMods(ids); + if (!*alive) { + co_return; + } + m_downloading = false; + if (!result) { + spdlog::warn("[workshop] queued download failed: {}", + result.error().message.toStdString()); + } + m_queued.subtract(QSet(ids.begin(), ids.end())); + queueChanged.notify(std::monostate{}); + if (m_downloads->cancelled()) { + cancelQueue(); + break; + } + } + m_draining = false; +} + +void WorkshopCatalog::resumeIdleWaiters() { + auto waiters = std::move(m_idleWaiters); + m_idleWaiters.clear(); + for (std::coroutine_handle<> waiter : waiters) { + waiter.resume(); + } +} + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/catalog.hpp b/src/core/workshop/catalog.hpp new file mode 100644 index 0000000..a5e6ba4 --- /dev/null +++ b/src/core/workshop/catalog.hpp @@ -0,0 +1,300 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/downloads/manager.hpp" +#include "core/error.hpp" +#include "core/events.hpp" +#include "core/file_io.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/plugin.hpp" +#include "core/plugin_config.hpp" +#include "core/workshop/client.hpp" +#include "core/workshop/favourites.hpp" +#include "core/workshop/types.hpp" + +namespace px::modrim::workshop { + +inline constexpr std::string_view kDefaultCatalogUrl = + "https://api.modrim.pyxiion.dev"; + +// A value remembered for less than this many seconds is served without +// contacting the catalog. +inline constexpr double kFreshSeconds = 300.0; + +// Stored as plugins/workshop_catalog.json. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct CatalogSettings { + std::string url = std::string(kDefaultCatalogUrl); + + bool operator==(const CatalogSettings&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// The installed mods the catalog compares against. The composition root +// supplies it, so the catalog does not reach for global state. +using InstalledModsProvider = std::function()>; + +// A cached value with the time it was fetched (seconds since the epoch). +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +template +struct Remembered { + double at = 0; + T value{}; +}; +// NOLINTEND(readability-identifier-naming) + +namespace detail { + +// A missing, unreadable or undecodable cache file is treated as a miss. +template +std::optional> readCached(const QString& path) { + auto bytes = readFileBytes(path); + if (!bytes) { + return std::nullopt; + } + Remembered entry; + if (glz::read(entry, *bytes)) { + return std::nullopt; + } + return entry; +} + +// Writes one cache file, then drops the oldest files beyond the disk limit. +Result writeCached(const QString& directory, const QString& path, + const std::string& text); + +} // namespace detail + +// The Workshop catalog: remote browsing and resolution of mods, plus a download +// queue that runs behind the downloads manager. The catalog never mutates +// installed mods itself. +class WorkshopCatalog : public Plugin { + public: + WorkshopCatalog(); + void setup(CoreContext& ctx) override; + // `config` may be null: settings, favourites and the response cache then stay + // in memory. + WorkshopCatalog(ConfigService* config, DownloadManager& downloads, + InstalledModsProvider installedMods); + ~WorkshopCatalog(); + + WorkshopCatalog(const WorkshopCatalog&) = delete; + WorkshopCatalog& operator=(const WorkshopCatalog&) = delete; + + // Stops the queue, aborts requests, and waits for the queue runner to finish. + QCoro::Task shutdown() override; + + Event catalogUrlChanged; + Event installedChanged; + Event queueChanged; + + const CatalogSettings& settings() const noexcept { + return m_settings.value(); + } + void setBaseUrl(const QString& url); + bool configured() const noexcept { return !m_baseUrl.isEmpty(); } + + FavouriteCollections& favourites() noexcept { return m_favourites; } + + QCoro::Task> discover(); + QCoro::Task>> mods(CatalogQuery query); + QCoro::Task>> collections( + CatalogQuery query); + QCoro::Task>> mod(QString id); + QCoro::Task>> collection( + QString id); + + // Serves the remembered value at once through `show`, then the refreshed one + // when it differs. A value younger than `fresh_for` is served without + // contacting the catalog. With `persist`, the value also survives a restart. + // `T` must be serialisable with glaze and comparable. + template + QCoro::Task> cached( + QString key, std::function>()> produce, + std::function show, double freshFor = kFreshSeconds, + bool persist = false); + + // Installed state is cached until invalidate_installed(). + InstallState installState(const CatalogMod& mod); + QCoro::Task>> installedWithUpdates(); + + // Resolves ids into the mods to fetch, skipping those already current. + QCoro::Task> plan(QStringList modIds, + QStringList collectionIds); + QCoro::Task> download(DownloadPlan downloadPlan); + + // Ids waiting in the queue or downloading. + QSet queuedIds() const { return m_queued; } + // Queues `plan` behind any running download without waiting for it. + void enqueue(const DownloadPlan& downloadPlan); + void cancelQueue(); + + // Call when the installed mods changed (mods changed, config changed). + void invalidateInstalled(); + + private: + using InstalledIndex = QHash>; + + // Suspends the queue until the downloads report they are idle again. + struct IdleWait { + WorkshopCatalog& catalog; + + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> handle) const { + catalog.m_idleWaiters.push_back(handle); + } + void await_resume() const noexcept {} + // NOLINTEND(readability-identifier-naming) + }; + + CatalogClient& client(); + const InstalledIndex& installed(); + void bindDownloads(DownloadManager& downloads); + void disconnectEvents(); + InstallState installStateOf(const CatalogMod& mod, + const InstalledIndex& installed) const; + void changeUrl(const QString& url); + void resumeIdleWaiters(); + QCoro::Task drain(); + + template + std::optional> recall(const QString& key) const; + // Keeps the memory cache within its limit. `move_to_end` marks the entry as + // the newest. + void remember(const QString& key, std::any value, bool moveToEnd); + QString cacheFile(const QString& key) const; + + PluginConfig m_settings; + QString m_baseUrl; + std::optional m_cacheDir; + FavouriteCollections m_favourites; + DownloadManager* m_downloads = nullptr; + InstalledModsProvider m_installedMods; + Event::Id m_downloadFinishedId = 0; + Event::Id m_busyChangedId = 0; + CoreContext* m_context = nullptr; + Event::Id m_modsChangedId = 0; + Event::Id m_configChangedId = 0; + + std::optional m_installedIndex; + // Insertion-ordered: the oldest entries are evicted first. + QList m_responseOrder; + // Each value is a Remembered for the T it was stored with. + QHash m_responses; + + // Every client made so far stays alive until shutdown, so requests in flight + // on a replaced client finish. + std::vector> m_clients; + CatalogClient* m_client = nullptr; + + QList m_queue; + QSet m_queued; + // Resetting a QCoro task does not cancel its suspended coroutine. + std::shared_ptr m_alive = std::make_shared(true); + bool m_stopping = false; + bool m_downloading = false; + bool m_draining = false; + std::optional> m_runner; + std::vector> m_idleWaiters; +}; + +template +std::optional> WorkshopCatalog::recall(const QString& key) const { + const auto found = m_responses.constFind(key); + if (found == m_responses.constEnd()) { + return std::nullopt; + } + const auto* typed = std::any_cast>(&found.value()); + if (!typed) { + return std::nullopt; + } + return *typed; +} + +template +QCoro::Task> WorkshopCatalog::cached( + QString key, std::function>()> produce, + std::function show, double freshFor, bool persist) { + const QString fullKey = m_baseUrl + QLatin1Char('|') + key; + std::optional path; + if (persist && m_cacheDir) { + path = cacheFile(fullKey); + } + + std::optional> entry = recall(fullKey); + if (!entry && path) { + entry = co_await QtConcurrent::run( + [path = *path] { return detail::readCached(path); }); + if (entry) { + remember(fullKey, *entry, false); + } + } + if (entry) { + show(entry->value); + const double age = QDateTime::currentMSecsSinceEpoch() / 1000.0 - entry->at; + if (age < freshFor) { + co_return {}; + } + } + + auto fetched = co_await produce(); + if (!fetched) { + co_return std::unexpected(fetched.error()); + } + T value = std::move(*fetched); + const bool changed = !entry || value != entry->value; + if (entry && !changed) { + // Keep the displayed copy so an equal refresh does not replace what is on + // screen. + value = entry->value; + } + const Remembered remembered{QDateTime::currentMSecsSinceEpoch() / 1000.0, + value}; + remember(fullKey, remembered, true); + + if (path) { + auto encoded = glz::write(remembered); + if (encoded) { + auto saved = co_await QtConcurrent::run( + [directory = *m_cacheDir, file = *path, bytes = std::move(*encoded)] { + return detail::writeCached(directory, file, bytes); + }); + if (!saved) { + spdlog::warn("[workshop] cannot write cache: {}", + saved.error().message.toStdString()); + } + } + } + if (changed) { + show(value); + } + co_return {}; +} + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/client.cpp b/src/core/workshop/client.cpp new file mode 100644 index 0000000..400cdf7 --- /dev/null +++ b/src/core/workshop/client.cpp @@ -0,0 +1,340 @@ +#include "core/workshop/client.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace px::modrim::workshop { + +// glaze reflection needs these types to have external linkage; clang rejects +// reflecting types from an anonymous namespace. +namespace wire { + +struct ErrorMessage { + std::string message; +}; + +struct ErrorEnvelope { + ErrorMessage error; +}; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct BatchWire { + std::vector items; + std::vector unavailable_ids; +}; +// NOLINTEND(readability-identifier-naming) + +struct IdsBody { + std::vector ids; +}; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct ResolveBody { + std::vector ids; + std::vector collection_ids; +}; +// NOLINTEND(readability-identifier-naming) + +} // namespace wire + +using namespace wire; + +namespace { + +constexpr int kRequestTimeoutMs = 30000; +// Per-request limits of the catalog service; larger inputs are split across +// requests. +constexpr qsizetype kIdsPerBatch = 100; +constexpr qsizetype kModIdsPerResolve = 50; +constexpr qsizetype kCollectionIdsPerResolve = 10; + +std::vector toUtf8(const QStringList& list) { + std::vector out; + out.reserve(static_cast(list.size())); + for (const QString& value : list) { + out.push_back(value.toStdString()); + } + return out; +} + +// Percent-encodes everything except the unreserved characters, as Python's +// quote(safe="") does. +QString encodeSegment(const QString& segment) { + return QString::fromLatin1(QUrl::toPercentEncoding(segment)); +} + +QUrlQuery queryOf(const CatalogQuery& query) { + QUrlQuery params; + params.addQueryItem(QStringLiteral("q"), query.query); + params.addQueryItem(QStringLiteral("sort"), query.sort); + params.addQueryItem(QStringLiteral("limit"), QString::number(query.limit)); + params.addQueryItem(QStringLiteral("source"), query.source); + if (query.tag) { + params.addQueryItem(QStringLiteral("tag"), *query.tag); + } + if (query.version) { + params.addQueryItem(QStringLiteral("version"), *query.version); + } + if (query.cursor) { + params.addQueryItem(QStringLiteral("cursor"), *query.cursor); + } + return params; +} + +template +std::string toJson(const T& value) { + auto text = glz::write(value); + Q_ASSERT(text.has_value()); + return std::move(*text); +} + +template +CatalogResult decode(const std::string& body, int status) { + T value{}; + if (auto error = glz::read(value, body)) { + return std::unexpected(CatalogError{ + QStringLiteral("The Workshop catalog returned an invalid response."), + status}); + } + return value; +} + +// Appends the values not seen before, keeping first-occurrence order (Python +// dict.fromkeys). +void addUnique(std::vector& out, + std::unordered_set& seen, + const std::vector& values) { + for (const std::string& value : values) { + if (seen.insert(value).second) { + out.push_back(value); + } + } +} + +} // namespace + +QString normalizeBaseUrl(QString url) { + url = url.trimmed(); + while (url.endsWith(u'/')) { + url.chop(1); + } + return url; +} + +CatalogClient::CatalogClient(QString baseUrl) + : m_baseUrl(normalizeBaseUrl(std::move(baseUrl))) {} + +void CatalogClient::shutdown() { + // abort() can resume waiting requests, which remove themselves from pending_: + // iterate a copy. + const auto replies = m_pending; + for (QNetworkReply* reply : replies) { + reply->abort(); + } +} + +QUrl CatalogClient::endpoint(const QString& path, + const QUrlQuery& query) const { + QUrl url(m_baseUrl + QStringLiteral("/catalog/") + path); + url.setQuery(query); + return url; +} + +QCoro::Task> CatalogClient::send( + bool post, QUrl url, std::string payload) { + if (m_baseUrl.isEmpty()) { + co_return std::unexpected(CatalogError{ + QStringLiteral("Workshop catalog is disabled."), std::nullopt}); + } + + QNetworkRequest request(url); + request.setTransferTimeout(kRequestTimeoutMs); + QNetworkReply* reply = nullptr; + if (post) { + request.setHeader(QNetworkRequest::ContentTypeHeader, + QByteArrayLiteral("application/json")); + reply = m_network.post(request, QByteArray::fromStdString(payload)); + } else { + reply = m_network.get(request); + } + m_pending.insert(reply); + const auto release = qScopeGuard([this, reply] { + m_pending.remove(reply); + reply->deleteLater(); + }); + + co_await qCoro(reply).waitForFinished(); + + // An HTTP status is only present when the server answered; without one the + // request failed in transport. + const QVariant statusAttribute = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); + if (!statusAttribute.isValid()) { + co_return std::unexpected( + CatalogError{QStringLiteral("Cannot reach the Workshop catalog: %1") + .arg(reply->errorString()), + std::nullopt}); + } + + const int status = statusAttribute.toInt(); + Reply received{status, reply->readAll().toStdString()}; + if (status >= 200 && status < 300) { + co_return received; + } + + QString message; + ErrorEnvelope envelope; + if (!glz::read(envelope, received.body)) { + message = QString::fromStdString(envelope.error.message); + } else { + const QString reason = + reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); + message = + reason.isEmpty() + ? QStringLiteral("Workshop catalog request failed (HTTP %1).") + .arg(status) + : QStringLiteral("Workshop catalog request failed (HTTP %1 %2).") + .arg(status) + .arg(reason); + } + co_return std::unexpected(CatalogError{message, status}); +} + +template +QCoro::Task> CatalogClient::call(bool post, QUrl url, + std::string payload) { + auto reply = co_await send(post, std::move(url), std::move(payload)); + if (!reply) { + co_return std::unexpected(reply.error()); + } + co_return decode(reply->body, reply->status); +} + +QCoro::Task> CatalogClient::discover() { + co_return co_await call(false, + endpoint(QStringLiteral("discover"))); +} + +QCoro::Task>> CatalogClient::mods( + CatalogQuery query) { + co_return co_await call>( + false, endpoint(QStringLiteral("mods"), queryOf(query))); +} + +QCoro::Task>> +CatalogClient::collections(CatalogQuery query) { + co_return co_await call>( + false, endpoint(QStringLiteral("collections"), queryOf(query))); +} + +QCoro::Task>> CatalogClient::mod( + QString id) { + auto found = co_await call( + false, endpoint(QStringLiteral("mods/") + encodeSegment(id))); + if (found) { + co_return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + co_return std::optional{}; + } + co_return std::unexpected(found.error()); +} + +QCoro::Task>> +CatalogClient::collection(QString id) { + // Collection ids are ":"; the key may itself contain colons. + const qsizetype colon = id.indexOf(u':'); + const QString source = colon < 0 ? QString() : id.left(colon); + const QString key = colon < 0 ? QString() : id.mid(colon + 1); + if (colon < 0 || key.isEmpty() || + (source != QStringLiteral("steam") && + source != QStringLiteral("picked"))) { + co_return std::unexpected(CatalogError{ + QStringLiteral("Invalid Workshop collection ID."), std::nullopt}); + } + + auto found = co_await call( + false, endpoint(QStringLiteral("collections/") + source + + QLatin1Char('/') + encodeSegment(key))); + if (found) { + co_return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + co_return std::optional{}; + } + co_return std::unexpected(found.error()); +} + +QCoro::Task> CatalogClient::modsBatch( + QStringList ids) { + BatchResult result; + for (qsizetype start = 0; start < ids.size(); start += kIdsPerBatch) { + auto batch = co_await call( + true, endpoint(QStringLiteral("mods/batch")), + toJson(IdsBody{toUtf8(ids.mid(start, kIdsPerBatch))})); + if (!batch) { + co_return std::unexpected(batch.error()); + } + result.items.insert(result.items.end(), + std::make_move_iterator(batch->items.begin()), + std::make_move_iterator(batch->items.end())); + result.unavailableIds.insert(result.unavailableIds.end(), + batch->unavailable_ids.begin(), + batch->unavailable_ids.end()); + } + co_return result; +} + +QCoro::Task> CatalogClient::resolve( + QStringList ids, QStringList collectionIds) { + // One request covers up to 50 mods and 10 collections; the longer list sets + // the number of requests. + const qsizetype batches = + std::max({(ids.size() + kModIdsPerResolve - 1) / kModIdsPerResolve, + (collectionIds.size() + kCollectionIdsPerResolve - 1) / + kCollectionIdsPerResolve, + qsizetype{1}}); + ResolvedDownload merged; + merged.is_complete = true; + std::unordered_set rootsSeen; + std::unordered_set modIdsSeen; + std::unordered_set unavailableSeen; + std::unordered_set incompleteSeen; + for (qsizetype batch = 0; batch < batches; ++batch) { + ResolveBody body{ + toUtf8(ids.mid(batch * kModIdsPerResolve, kModIdsPerResolve)), + toUtf8(collectionIds.mid(batch * kCollectionIdsPerResolve, + kCollectionIdsPerResolve)), + }; + auto resolved = co_await call( + true, endpoint(QStringLiteral("resolve")), toJson(body)); + if (!resolved) { + co_return std::unexpected(resolved.error()); + } + addUnique(merged.roots, rootsSeen, resolved->roots); + for (auto& [id, entry] : resolved->items) { + merged.items.insert_or_assign(id, std::move(entry)); + } + addUnique(merged.mod_ids, modIdsSeen, resolved->mod_ids); + addUnique(merged.unavailable_ids, unavailableSeen, + resolved->unavailable_ids); + addUnique(merged.incomplete_collection_ids, incompleteSeen, + resolved->incomplete_collection_ids); + merged.is_complete = merged.is_complete && resolved->is_complete; + } + co_return merged; +} + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/client.hpp b/src/core/workshop/client.hpp new file mode 100644 index 0000000..992609d --- /dev/null +++ b/src/core/workshop/client.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/workshop/types.hpp" + +namespace px::modrim::workshop { + +// Trims whitespace and trailing slashes, so "https://x/" and "https://x" name +// the same catalog. +QString normalizeBaseUrl(QString url); + +// HTTP client for the Workshop catalog service. An empty base URL disables the +// catalog: every call fails with a CatalogError and nothing is sent. +class CatalogClient { + public: + struct BatchResult { + std::vector items; + std::vector unavailableIds; + }; + + explicit CatalogClient(QString baseUrl); + CatalogClient(const CatalogClient&) = delete; + CatalogClient& operator=(const CatalogClient&) = delete; + + // Aborts requests still in flight; each one completes with a CatalogError. + void shutdown(); + + QCoro::Task> discover(); + QCoro::Task>> mods(CatalogQuery query); + QCoro::Task>> collections( + CatalogQuery query); + // nullopt when the catalog does not know the mod (HTTP 404). + QCoro::Task>> mod(QString id); + // nullopt when the catalog does not know the collection (HTTP 404). + QCoro::Task>> collection( + QString id); + QCoro::Task> modsBatch(QStringList ids); + QCoro::Task> resolve( + QStringList ids, QStringList collectionIds); + + private: + struct Reply { + int status = 0; + std::string body; + }; + + template + QCoro::Task> call(bool post, QUrl url, + std::string payload = {}); + + QCoro::Task> send(bool post, QUrl url, + std::string payload); + QUrl endpoint(const QString& path, const QUrlQuery& query = {}) const; + + QString m_baseUrl; + QNetworkAccessManager m_network; + // Requests in flight, aborted by shutdown(). + QSet m_pending; +}; + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.cpp b/src/core/workshop/favourites.cpp new file mode 100644 index 0000000..7d59656 --- /dev/null +++ b/src/core/workshop/favourites.cpp @@ -0,0 +1,61 @@ +#include "core/workshop/favourites.hpp" + +#include +#include + +namespace px::modrim::workshop { + +FavouriteCollections::FavouriteCollections(ConfigService* service) + : m_config(service, QStringLiteral("workshop_favourites")) { + for (const CatalogCollection& collection : m_config.value().collections) { + m_ids.insert(collection.id); + } +} + +bool FavouriteCollections::contains(const std::string& collectionId) const { + return m_ids.contains(collectionId); +} + +void FavouriteCollections::set(const CatalogCollection& collection, + bool favourite) { + if (contains(collection.id) == favourite) { + return; + } + std::vector rest; + for (const CatalogCollection& stored : m_config.value().collections) { + if (stored.id != collection.id) { + rest.push_back(stored); + } + } + if (favourite) { + rest.insert(rest.begin(), collection); + } + save(std::move(rest)); +} + +void FavouriteCollections::remember(const CatalogCollection& collection) { + if (!contains(collection.id)) { + return; + } + const auto& stored = m_config.value().collections; + if (std::ranges::find(stored, collection) != stored.end()) { + return; + } + std::vector updated = stored; + for (CatalogCollection& entry : updated) { + if (entry.id == collection.id) { + entry = collection; + } + } + save(std::move(updated)); +} + +void FavouriteCollections::save(std::vector collections) { + m_ids.clear(); + for (const CatalogCollection& collection : collections) { + m_ids.insert(collection.id); + } + m_config.update(FavouritesState{std::move(collections)}); +} + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/favourites.hpp b/src/core/workshop/favourites.hpp new file mode 100644 index 0000000..7fd0041 --- /dev/null +++ b/src/core/workshop/favourites.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +#include "core/config.hpp" +#include "core/events.hpp" +#include "core/plugin_config.hpp" +#include "core/workshop/types.hpp" + +namespace px::modrim::workshop { + +// The persisted form, plugins/workshop_favourites.json. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct FavouritesState { + std::vector collections; + + bool operator==(const FavouritesState&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// Collections the user starred, newest first. Each entry is the last catalog +// snapshot of the collection, so the list shows without a network round trip +// and survives the catalog dropping it. +class FavouriteCollections { + public: + // Without a service the favourites live in memory only. + explicit FavouriteCollections(ConfigService* service); + + Event& changed() noexcept { return m_config.changed; } + const std::vector& items() const noexcept { + return m_config.value().collections; + } + bool contains(const std::string& collectionId) const; + + void set(const CatalogCollection& collection, bool favourite); + // Refreshes the stored snapshot of a starred collection; other collections + // are ignored. + void remember(const CatalogCollection& collection); + + private: + void save(std::vector collections); + + PluginConfig m_config; + std::unordered_set m_ids; +}; + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/types.hpp b/src/core/workshop/types.hpp new file mode 100644 index 0000000..3fdc7fb --- /dev/null +++ b/src/core/workshop/types.hpp @@ -0,0 +1,273 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace px::modrim::workshop { + +// Catalog responses are strict: every key is present (nullable ones may be +// null), unknown keys are ignored and a value of the wrong type fails. Writing +// emits nulls so payloads round-trip exactly. +inline constexpr glz::opts kWireOpts{ + .error_on_unknown_keys = false, + .skip_null_members = false, + .error_on_missing_keys = true, +}; + +// Literal-typed wire fields are enums so that a value outside the catalog's +// contract fails to decode. +enum class PreviewType { kImage, kVideo }; +enum class ModSource { kSteam }; +enum class ModDescriptionFormat { kBbcode }; +enum class ModKind { kMod }; +enum class CollectionSource { kSteam, kPicked }; +enum class CollectionDescriptionFormat { kBbcode, kText }; +enum class CollectionKind { kCollection }; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct Author { + std::optional id; + std::optional name; + std::optional profile_url; + + bool operator==(const Author&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct Preview { + PreviewType type = PreviewType::kImage; + std::string url; + + bool operator==(const Preview&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct Votes { + std::int64_t up = 0; + std::int64_t down = 0; + std::optional positive_percent; + + bool operator==(const Votes&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct CatalogMod { + std::string id; + ModSource source = ModSource::kSteam; + std::string title; + Author author; + std::string description; + ModDescriptionFormat description_format = ModDescriptionFormat::kBbcode; + std::optional preview_url; + std::vector previews; + std::string workshop_url; + std::vector tags; + std::vector supported_versions; + std::optional created_at; + std::optional updated_at; + std::optional file_size; + std::optional subscriptions; + std::optional votes; + std::vector dependencies; + std::optional package_id; + bool incompatible = false; + // The wire tag: decoding a collection payload as a mod fails here. + ModKind kind = ModKind::kMod; + + bool operator==(const CatalogMod&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct CatalogCollection { + std::string id; + CollectionSource source = CollectionSource::kSteam; + std::optional steam_id; + std::string title; + Author author; + std::string description; + CollectionDescriptionFormat description_format = + CollectionDescriptionFormat::kText; + std::optional preview_url; + std::vector previews; + std::optional workshop_url; + std::vector tags; + std::vector supported_versions; + std::optional created_at; + std::optional updated_at; + std::vector member_ids; + std::int64_t member_count = 0; + std::vector member_previews; + std::optional total_size; + bool no_common_version = false; + // The wire tag: decoding a mod payload as a collection fails here. + CollectionKind kind = CollectionKind::kCollection; + + bool operator==(const CatalogCollection&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// A mod or a collection, told apart by the wire `kind` tag. +using CatalogEntry = std::variant; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +template +struct CatalogPage { + std::vector items; + std::int64_t total = 0; + std::optional next_cursor; + + bool operator==(const CatalogPage&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct CollectionDetail { + CatalogCollection collection; + std::vector members; + std::vector unavailable_ids; + bool is_complete = false; + + bool operator==(const CollectionDetail&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct Discover { + CatalogPage mods; + CatalogPage collections; + + bool operator==(const Discover&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct ResolvedDownload { + std::vector roots; + // Not std::map: libc++'s map operator<=> is not SFINAE-friendly, so glaze's + // ordered<> probe hard-errors on a variant of unordered alternatives. + std::unordered_map items; + std::vector mod_ids; + std::vector unavailable_ids; + std::vector incomplete_collection_ids; + bool is_complete = false; + + bool operator==(const ResolvedDownload&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct CatalogQuery { + QString query; + std::optional tag; + std::optional version; + QString sort = QStringLiteral("popular"); + int limit = 24; + std::optional cursor; + QString source = QStringLiteral("all"); +}; +// NOLINTEND(readability-identifier-naming) + +enum class InstallState { kMissing, kInstalled, kOutdated }; + +// What a download would fetch, in dependency order. Built on the client side, +// so it uses Qt types. NOLINTBEGIN(readability-identifier-naming): fields are +// JSON keys on disk or on the wire +struct DownloadPlan { + QStringList to_download{}; + QStringList already_current{}; + QStringList unavailable_ids{}; + QStringList incomplete_collection_ids{}; + QHash titles{}; + bool is_complete = false; +}; +// NOLINTEND(readability-identifier-naming) + +// Failure of a catalog request. `status` is the HTTP status when the server +// answered, empty when the request never got a response (disabled catalog, +// unreachable host, undecodable payload). +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct CatalogError { + QString message; + std::optional status; +}; +// NOLINTEND(readability-identifier-naming) + +template +using CatalogResult = std::expected; + +} // namespace px::modrim::workshop + +// The JSON strings are part of the catalog API, so they are spelled out instead +// of derived from the enumerator names. +// NOLINTBEGIN(readability-identifier-naming): glaze looks the member up by this +// exact name +template <> +struct glz::meta { + using enum px::modrim::workshop::PreviewType; + static constexpr auto value = + glz::enumerate("image", kImage, "video", kVideo); +}; + +template <> +struct glz::meta { + using enum px::modrim::workshop::ModSource; + static constexpr auto value = glz::enumerate("steam", kSteam); +}; + +template <> +struct glz::meta { + using enum px::modrim::workshop::ModDescriptionFormat; + static constexpr auto value = glz::enumerate("bbcode", kBbcode); +}; + +template <> +struct glz::meta { + using enum px::modrim::workshop::ModKind; + static constexpr auto value = glz::enumerate("mod", kMod); +}; + +template <> +struct glz::meta { + using enum px::modrim::workshop::CollectionSource; + static constexpr auto value = + glz::enumerate("steam", kSteam, "picked", kPicked); +}; + +template <> +struct glz::meta { + using enum px::modrim::workshop::CollectionDescriptionFormat; + static constexpr auto value = + glz::enumerate("bbcode", kBbcode, "text", kText); +}; + +template <> +struct glz::meta { + using enum px::modrim::workshop::CollectionKind; + static constexpr auto value = glz::enumerate("collection", kCollection); +}; +// NOLINTEND(readability-identifier-naming) diff --git a/src/core/xml.cpp b/src/core/xml.cpp new file mode 100644 index 0000000..1679182 --- /dev/null +++ b/src/core/xml.cpp @@ -0,0 +1,168 @@ +#include "core/xml.hpp" + +#include +#include +#include +#include +#include + +namespace px::modrim { +namespace { + +glz::json_t nodeValue(pugi::xml_node node) { + glz::json_t::object_t fields; + bool hasElements = false; + for (const auto child : node.children()) { + if (child.type() != pugi::node_element) continue; + hasElements = true; + auto value = nodeValue(child); + auto [position, inserted] = + fields.try_emplace(child.name(), std::move(value)); + if (inserted) continue; + if (auto* repeated = + std::get_if(&position->second.data)) { + repeated->push_back(std::move(value)); + } else { + glz::json_t::array_t entries; + entries.push_back(std::move(position->second)); + entries.push_back(std::move(value)); + position->second.data = std::move(entries); + } + } + const QString text = rawText(node).trimmed(); + glz::json_t result; + if (!hasElements && !node.first_attribute()) { + const auto first = node.first_child(); + if (first.type() == pugi::node_pcdata || first.type() == pugi::node_cdata) { + result.data = text.toStdString(); + } else { + result.data = glz::json_t::object_t{}; + } + return result; + } + for (const auto attribute : node.attributes()) { + fields[std::string("@") + attribute.name()].data = + std::string(attribute.value()); + } + if (!text.isEmpty()) fields["#text"].data = text.toStdString(); + result.data = std::move(fields); + return result; +} + +std::string scalarText(const glz::json_t& value) { + if (const auto* text = std::get_if(&value.data)) return *text; + if (const auto* boolean = std::get_if(&value.data)) { + return *boolean ? "True" : "False"; + } + if (std::holds_alternative(value.data)) return "None"; + return value.dump().value_or(std::string{}); +} + +void populateNode(pugi::xml_node node, const glz::json_t& value) { + if (const auto* fields = std::get_if(&value.data)) { + for (const auto& [key, item] : *fields) { + if (key.starts_with('@')) { + node.append_attribute(key.c_str() + 1) + .set_value(scalarText(item).c_str()); + } else if (key == "#text") { + // A leading text node must remain before children regardless of map + // order. + auto first = node.first_child(); + if (first.type() != pugi::node_pcdata && + first.type() != pugi::node_cdata) { + first = node.prepend_child(pugi::node_pcdata); + } + first.set_value(scalarText(item).c_str()); + } else if (const auto* entries = + std::get_if(&item.data)) { + for (const auto& entry : *entries) { + populateNode(node.append_child(key.c_str()), entry); + } + } else { + populateNode(node.append_child(key.c_str()), item); + } + } + } else if (const auto* entries = + std::get_if(&value.data)) { + for (const auto& entry : *entries) { + populateNode(node.append_child("li"), entry); + } + } else { + node.append_child(pugi::node_pcdata).set_value(scalarText(value).c_str()); + } +} + +} // namespace + +Result loadXmlFile(pugi::xml_document& doc, const QString& path) { + if (!QFileInfo::exists(path)) { + return std::unexpected( + Error{QStringLiteral("XML file does not exist at: %1").arg(path)}); + } + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return std::unexpected( + Error{QStringLiteral("Failed to read XML file %1: %2") + .arg(path, file.errorString())}); + } + const QByteArray contents = file.readAll(); + if (file.error() != QFileDevice::NoError) { + return std::unexpected( + Error{QStringLiteral("Failed to read XML file %1: %2") + .arg(path, file.errorString())}); + } + // Keep whitespace text, comments, and PIs so .text never crosses a node. + const auto parsed = doc.load_buffer( + contents.constData(), static_cast(contents.size()), + pugi::parse_default | pugi::parse_ws_pcdata | pugi::parse_comments | + pugi::parse_pi); + if (!parsed || !doc.document_element()) { + return std::unexpected( + Error{QStringLiteral("Failed to parse XML file %1: %2") + .arg(path, QString::fromUtf8(parsed.description()))}); + } + return {}; +} + +QString rawText(pugi::xml_node node) { + QString text; + for (const auto child : node.children()) { + if (child.type() != pugi::node_pcdata && child.type() != pugi::node_cdata) + break; + text += QString::fromUtf8(child.value()); + } + return text; +} + +std::optional childText(pugi::xml_node parent, const char* tag) { + const QString text = rawText(parent.child(tag)).trimmed(); + if (text.isEmpty()) return std::nullopt; + return text; +} + +glz::json_t elementToJson(pugi::xml_node element) { + glz::json_t result; + result.data = glz::json_t::object_t{{element.name(), nodeValue(element)}}; + return result; +} + +Result xmlFileToJson(const QString& path) { + pugi::xml_document doc; + if (auto loaded = loadXmlFile(doc, path); !loaded) { + return std::unexpected(loaded.error()); + } + return elementToJson(doc.document_element()); +} + +Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data) { + const auto* roots = std::get_if(&data.data); + if (!roots || roots->size() != 1) { + return std::unexpected( + Error{QStringLiteral("Dictionary must have exactly one root key")}); + } + const auto& [name, value] = *roots->begin(); + populateNode(parent.append_child(name.c_str()), value); + return {}; +} + +} // namespace px::modrim diff --git a/src/core/xml.hpp b/src/core/xml.hpp new file mode 100644 index 0000000..6365f98 --- /dev/null +++ b/src/core/xml.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +#include +#include + +#include "core/error.hpp" + +namespace px::modrim { + +Result loadXmlFile(pugi::xml_document& doc, const QString& path); +QString rawText(pugi::xml_node node); +std::optional childText(pugi::xml_node parent, const char* tag); +glz::json_t elementToJson(pugi::xml_node element); +Result xmlFileToJson(const QString& path); +Result appendJsonAsXml(pugi::xml_node parent, const glz::json_t& data); + +} // namespace px::modrim diff --git a/src/pxmodrim/__init__.py b/src/pxmodrim/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/__main__.py b/src/pxmodrim/__main__.py deleted file mode 100644 index 54f585f..0000000 --- a/src/pxmodrim/__main__.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -import sys - -from pxmodrim._app import App - - -def main() -> None: - """Entry point: instantiate App and run the Qt event loop.""" - app = App() - sys.exit(app.run()) - - -if __name__ == "__main__": - main() diff --git a/src/pxmodrim/_app.py b/src/pxmodrim/_app.py deleted file mode 100644 index a7be82a..0000000 --- a/src/pxmodrim/_app.py +++ /dev/null @@ -1,340 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import sys -import traceback -from importlib.resources import files as resource_files -from types import TracebackType - -from loguru import logger -from PySide6.QtGui import QColor, QIcon, QPalette -from PySide6.QtWidgets import QApplication, QDialog, QMessageBox -from qasync import QEventLoop - -# Qt6 defaults to PassThrough, which causes QtWebEngine to render at -# integer buffer-scale (e.g. 1×) while the compositor upscales fractionally -# (e.g. 1.25×, 1.5×) – results in pixelated content. -# Rounding down (< 0.75) avoids the blurred upscale at the cost of smaller UI; -# this matches the workaround used by Anki and qutebrowser for QTBUG-113574. -os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "RoundPreferFloor") -os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") -os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") - -_we_verbose = os.environ.get("PX_WEBENGINE_VERBOSE") -if _we_verbose is not None: - os.environ.setdefault( - "QTWEBENGINE_CHROMIUM_FLAGS", - f"--enable-logging --v={_we_verbose}", - ) - os.environ.setdefault( - "QT_LOGGING_RULES", - "qt.webenginecontext.*=true", - ) - -from pxmodrim.core.config import ( # noqa: E402 - AppConfig, - AppConfigService, - ConfigService, - config_dir, - detect_game_paths, -) -from pxmodrim.core.context import CoreContext # noqa: E402 -from pxmodrim.ui.components.dialogs import await_dialog # noqa: E402 -from pxmodrim.ui.components.popup_parent import QuickPopupParenting # noqa: E402 -from pxmodrim.ui.config import UIPrefsService # noqa: E402 -from pxmodrim.ui.context import AppContext # noqa: E402 -from pxmodrim.ui.panels.settings_panel import SettingsPanel # noqa: E402 -from pxmodrim.ui.theme.palette import PALETTE, get_stylesheet # noqa: E402 -from pxmodrim.ui.ui_prefs import UIPrefs # noqa: E402 -from pxmodrim.ui.views.mods_view import ModsViewPanel # noqa: E402 -from pxmodrim.ui.window.main_window import MainWindow # noqa: E402 - - -def _exception_hook( - exc_type: type[BaseException], - exc_value: BaseException, - exc_tb: TracebackType | None, -) -> None: - """Global exception handler for unhandled exceptions.""" - if exc_type is KeyboardInterrupt: - return - - error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb)) - logger.error("Unhandled exception:\n{}", error_msg) - app = QApplication.instance() - if app is not None: - QMessageBox.critical( - None, - "PxModRim - Crash", - f"An unexpected error occurred:\n\n" - f"{exc_type.__name__}: {exc_value}\n\n" - "Details have been logged.\n" - "You can upload diagnostics and logs via Help > Upload Log & System Info.", - ) - - -def _async_exception_handler(loop: asyncio.AbstractEventLoop, context: dict) -> None: - """Handle unhandled exceptions in async tasks.""" - exc = context.get("exception") - if exc: - logger.error("Unhandled async exception: {}", exc, exc_info=exc) - else: - logger.error("Unhandled async error: {}", context.get("message")) - - -sys.excepthook = _exception_hook - - -def _configure_file_logging() -> int: - logs_dir = config_dir() / "logs" - logs_dir.mkdir(parents=True, exist_ok=True) - return logger.add( - logs_dir / "pxmodrim.log", - level=os.environ.get("LOGURU_LEVEL", "INFO"), - rotation="10 MB", - retention=5, - serialize=True, - format="{time} | {level} | pid={process.id} | {module}:{line} | {message}", - ) - - -def _parse_disabled_plugins() -> set[str]: - disabled_raw = os.environ.get("PX_DISABLED_PLUGINS", "") - disabled = {n.strip() for n in disabled_raw.split(",") if n.strip()} - manifest = resource_files("pxmodrim") / "workshop-variant.txt" - if manifest.is_file(): - exclusions = { - "NoWorkshop": { - "steam_downloader", - "steamworkshop", - "downloads_ui", - "workshop_catalog", - "workshop_ui", - }, - "NativeWorkshop": {"steamworkshop"}, - "SteamWorkshop": {"workshop_catalog", "workshop_ui"}, - } - disabled.update(exclusions[manifest.read_text(encoding="utf-8").strip()]) - return disabled - - -_WHEEL_SCROLL_LINES = 8 - - -class App: - """Top-level application class wiring together Qt, services, and the main window.""" - - __slots__ = ( - "_app_ctx", - "_ctx", - "_disabled_plugins", - "main_window", - "qt_app", - ) - - def __init__(self) -> None: - _configure_file_logging() - self._disabled_plugins = _parse_disabled_plugins() - if ( - "steamworkshop" not in self._disabled_plugins - and "steam_downloader" not in self._disabled_plugins - ): - from PySide6.QtWebEngineQuick import QtWebEngineQuick - - QtWebEngineQuick.initialize() - - self._ctx: CoreContext | None = None - self.qt_app = QApplication(sys.argv) - self.qt_app.installEventFilter(QuickPopupParenting(self.qt_app)) - # async_run owns the exit: plugin shutdown awaits I/O after the window closes. - self.qt_app.setQuitOnLastWindowClosed(False) - icon = QIcon(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) - self.qt_app.setWindowIcon(icon) - self.qt_app.setApplicationName("PxModRim") - self.qt_app.setOrganizationName("PxModRim") - self.qt_app.setApplicationDisplayName("PxModRim") - self.qt_app.setDesktopFileName("pxmodrim") - - # Honoured by widgets and Qt Quick alike; the default of 3 feels slow. - self.qt_app.styleHints().setWheelScrollLines(_WHEEL_SCROLL_LINES) - self._apply_theme() - - def _apply_theme(self) -> None: - """Set Fusion style, dark palette, and stylesheet.""" - self.qt_app.setStyle("Fusion") - - palette = QPalette() - palette.setColor(QPalette.ColorRole.Window, QColor(PALETTE["ELEVATE_0"])) - palette.setColor(QPalette.ColorRole.WindowText, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.Base, QColor(PALETTE["ELEVATE_1"])) - palette.setColor(QPalette.ColorRole.AlternateBase, QColor(PALETTE["ELEVATE_2"])) - palette.setColor(QPalette.ColorRole.Text, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.Button, QColor(PALETTE["ELEVATE_2"])) - palette.setColor(QPalette.ColorRole.ButtonText, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.BrightText, QColor(PALETTE["TEXT_MAIN"])) - palette.setColor(QPalette.ColorRole.Highlight, QColor(PALETTE["PRIMARY"])) - palette.setColor( - QPalette.ColorRole.HighlightedText, QColor(PALETTE["ELEVATE_0"]) - ) - palette.setColor(QPalette.ColorRole.Link, QColor(PALETTE["PRIMARY"])) - palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(PALETTE["ELEVATE_2"])) - palette.setColor(QPalette.ColorRole.ToolTipText, QColor(PALETTE["TEXT_MAIN"])) - self.qt_app.setPalette(palette) - - try: - self.qt_app.setStyleSheet(get_stylesheet()) - except (FileNotFoundError, KeyError) as exc: - logger.warning("Failed to load theme: {}", exc) - - def _setup( - self, - cfg: AppConfig, - config_svc: ConfigService, - ui_prefs: UIPrefs | None = None, - ) -> CoreContext: - """Initialize CoreContext, services, and the main window via constructor DI.""" - - ctx = CoreContext.create(cfg, config_svc) - self._ctx = ctx - self._app_ctx = AppContext(ctx, ui_prefs) - self._app_ctx.add_rail_view(ModsViewPanel) - - from pxmodrim.core.organizer import OrganizerDb, OrganizerService - from pxmodrim.core.organizer.db import db_path - - self._ctx.register_plugin( - OrganizerService(ctx, OrganizerDb(db_path(config_svc.config_dir))) - ) - from pxmodrim.ui.plugins.organizer import OrganizerUiPlugin - - self._app_ctx.register_plugin(OrganizerUiPlugin()) - - disabled = self._disabled_plugins - - downloaders: list[str] = [] - steam_enabled = "steam_downloader" not in disabled - if steam_enabled: - try: - from pxmodrim.core.downloads.steam import SteamDownloader - except ImportError: - logger.info("pxsteamdl is not installed; Steam downloads disabled") - steam_enabled = False - else: - self._ctx.register_plugin(SteamDownloader()) - downloaders.append("steam") - from pxmodrim.ui.plugins.steam_downloader import ( - SteamDownloaderUiPlugin, - ) - - self._app_ctx.register_plugin(SteamDownloaderUiPlugin()) - - if "steam" in downloaders and "steamworkshop" not in disabled: - from pxmodrim.ui.plugins.steam_workshop import SteamWorkshopUiPlugin - - self._app_ctx.register_plugin(SteamWorkshopUiPlugin()) - - # The Downloads tab is source-agnostic: shown whenever any downloader exists. - if downloaders and "downloads_ui" not in disabled: - from pxmodrim.ui.plugins.downloads import DownloadsUiPlugin - - self._app_ctx.register_plugin(DownloadsUiPlugin()) - - if downloaders and "workshop_catalog" not in disabled: - from pxmodrim.core.workshop import WorkshopCatalog - - self._ctx.register_plugin(WorkshopCatalog()) - if "workshop_ui" not in disabled: - from pxmodrim.ui.plugins.workshop import WorkshopUiPlugin - - self._app_ctx.register_plugin(WorkshopUiPlugin()) - - self._app_ctx.setup_all() - self.main_window = MainWindow(self._app_ctx) - return ctx - - async def async_run(self) -> int: - """Run the app: migrate config, create services, open window, run event loop.""" - - logger.info("Starting PxModRim") - - # Phase 1: config migration - config_svc = ConfigService(config_dir()) - app_cfg_svc = AppConfigService(config_svc) - ui_prefs_svc = UIPrefsService(config_svc) - - try: - await app_cfg_svc.setup() - await ui_prefs_svc.setup() - except Exception: # noqa: BLE001 - configuration migration boundary - logger.exception("Failed to initialize configuration") - QMessageBox.critical( - None, - "PxModRim - Configuration Error", - "Failed to initialize configuration.\nCheck the logs for details.\n", - ) - return 1 - - cfg = app_cfg_svc.cfg() - ui_prefs = ui_prefs_svc.prefs() - logger.info( - "Configuration loaded: game={}, local={}, workshop={}", - cfg.paths.game or "", - cfg.paths.local or "", - cfg.paths.workshop or "", - ) - - if not cfg.paths.game: - logger.info("No game path in config, attempting auto-detect") - detected = detect_game_paths() - if detected.game: - cfg.paths = detected - app_cfg_svc.save_cfg() - - # Phase 2: service initialization - ctx = self._setup(cfg, config_svc, ui_prefs) - logger.info("Services initialized for game version {}", ctx.game_version) - - app_close_event = asyncio.Event() - self.qt_app.aboutToQuit.connect(app_close_event.set) - self.main_window.set_app_quit_callback(app_close_event.set) - self.main_window.show() - - if not ctx.config.paths.game: - logger.info("No game path found, showing settings dialog") - result, dialog = await await_dialog( - SettingsPanel, - ctx, - self.main_window.qml_engine, - self.main_window, - self._app_ctx.settings_sections, - ) - if app_close_event.is_set(): - await self._app_ctx.shutdown_all() - return 0 - new_cfg = dialog.get_config() - if result != QDialog.DialogCode.Accepted or not new_cfg.paths.game: - logger.warning("No game path configured, exiting") - return 1 - ctx.config_service.save("config.json", new_cfg) - ctx.update_config(new_cfg) - ctx.reset_providers(new_cfg.paths) - - mod_count = await self._app_ctx.refresh_mods() - if not app_close_event.is_set(): - logger.info("Initial mod load complete: {} mods", mod_count) - await self._app_ctx.init_all() - self.main_window.start_startup_update_check() - await app_close_event.wait() - await self._app_ctx.shutdown_all() - return 0 - - def run(self) -> int: - """Create a qasync event loop and run the application synchronously.""" - loop = QEventLoop() - loop.set_exception_handler(_async_exception_handler) - try: - return asyncio.run(self.async_run(), loop_factory=lambda: loop) - finally: - if self._ctx is not None: - self._ctx.close_sync() diff --git a/src/pxmodrim/core/__init__.py b/src/pxmodrim/core/__init__.py deleted file mode 100644 index abb57f7..0000000 --- a/src/pxmodrim/core/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from pxmodrim.core.context import CoreContext -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.structures import CollectionStats - -__all__ = ["CollectionStats", "CoreContext", "ModService"] diff --git a/src/pxmodrim/core/checker/__init__.py b/src/pxmodrim/core/checker/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/src/pxmodrim/core/checker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/src/pxmodrim/core/checker/checker.py b/src/pxmodrim/core/checker/checker.py deleted file mode 100644 index 07df9ef..0000000 --- a/src/pxmodrim/core/checker/checker.py +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Any - -from loguru import logger - -from pxmodrim.core.checker.graph import ConstraintGraph -from pxmodrim.core.checker.models import ( - CheckContext, - ModDiagnostics, - ModIssue, - PackageId, -) -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - -if TYPE_CHECKING: - from ttimer import Timer - - from pxmodrim.core.checker.issues import ModIssueChecker - from pxmodrim.core.sort.community import CommunityRule - from pxmodrim.core.sort.config import SortSettings - - -class ModChecker: - """Orchestrates diagnostic checks across active mods.""" - - __slots__ = ( - "_active_mods", - "_all_mods", - "_cached_cycles", - "_checker_results", - "_checkers", - "_community_rules", - "_diagnostics", - "_graph", - "_no_version_warning", - "_on_diagnostics_changed", - "_ordered_pids", - "_settings", - "_target_version", - "_use_this_instead", - "_uuid_to_pid", - ) - - def __init__( - self, - checkers: list[ModIssueChecker], - settings: SortSettings, - target_version: str = "1.5", - on_diagnostics_changed: Callable[[dict[str, ModDiagnostics]], Any] - | None = None, - ) -> None: - self._checkers = checkers - self._settings = settings - self._target_version = target_version - self._on_diagnostics_changed = on_diagnostics_changed - - self._graph = ConstraintGraph() - self._all_mods: dict[str, ListedMod] = {} - self._diagnostics: dict[PackageId, ModDiagnostics] = {} - self._active_mods: dict[PackageId, AboutXmlMod] = {} - self._uuid_to_pid: dict[str, PackageId] = {} - self._ordered_pids: list[PackageId] = [] - - self._no_version_warning: set[PackageId] = set() - self._use_this_instead: Mapping[str, Any] = {} - self._community_rules: dict[PackageId, CommunityRule] | None = None - self._cached_cycles: list[list[PackageId]] = [] - # Per-mod issues from each checker (checker order), reused by reorder() - # for order-independent checkers; None when stale. - self._checker_results: dict[PackageId, list[list[ModIssue]]] | None = None - - # ── Database config ─────────────────────────────────────── - - def set_no_version_warning(self, pids: set[PackageId]) -> None: - self._no_version_warning = pids - self._checker_results = None - - def set_use_this_instead(self, db: Mapping[str, Any]) -> None: - self._use_this_instead = db - self._checker_results = None - - def set_community_rules(self, rules: dict[PackageId, CommunityRule] | None) -> None: - self._community_rules = rules - - # ── Rebuild ─────────────────────────────────────────────── - - def rebuild( - self, - mods: dict[str, ListedMod], - ordered_uuids: list[str], - timer: Timer | None = None, - ) -> None: - """Re-scan all mods and regenerate diagnostics from scratch.""" - from ttimer import Timer - - t = timer or Timer() - - with t("collect_active"): - self._collect_active(mods, ordered_uuids) - - with t("graph.build"): - self._graph.build( - self._active_mods, - self._ordered_pids, - self._settings, - self._community_rules, - ) - - with t("find_cycles"): - self._cached_cycles = self._graph.find_cycles() - ctx = self._build_context(self._cached_cycles) - - with t("check_mod"): - self._check_all(ctx) - - self._emit() - - # ── Incremental updates ─────────────────────────────────── - - def toggle_mod( - self, - mod: ListedMod, - active: bool, - ordered_uuids: list[str], - ) -> None: - """Rebuild constraints and diagnostics after toggling a mod.""" - if not isinstance(mod, AboutXmlMod): - self._emit() - return - - self.rebuild(self._all_mods, ordered_uuids) - - def move_mod(self, uuid: str, old_index: int, new_index: int) -> None: - """Move a mod to new position and recheck diagnostics for neighbors.""" - if not self._ordered_pids: - return - - pid = self._uuid_to_pid.get(uuid) - if pid is None: - return - - self._ordered_pids.insert(new_index, self._ordered_pids.pop(old_index)) - self._graph.update_order(self._ordered_pids) - - ctx = self._build_context(self._cached_cycles) - # Only neighbours are rechecked, so cached results no longer match the order. - self._checker_results = None - - affected = {pid} - affected.update(self._graph.neighbors(pid)) - for current_pid in affected: - if current_pid in self._active_mods: - current_mod = self._active_mods[current_pid] - diag = self._check_mod(current_mod, ctx) - self._diagnostics[current_pid] = diag - - self._emit() - - def reorder(self, ordered_uuids: list[str]) -> None: - """Reapply the full active-mod order and regenerate all diagnostics.""" - previous = self._active_mods - previous_index = self._graph.pid_to_index - self._collect_active(self._all_mods, ordered_uuids) - self._graph.update_order(self._ordered_pids) - - cycles = self._graph.find_cycles() - ctx = self._build_context(cycles) - - results = self._checker_results - if results is None or not _same_mods(previous, self._active_mods): - self._check_all(ctx) - else: - moved = { - pid - for pid, index in ctx.pid_to_index.items() - if previous_index.get(pid) != index - } - changed: set[PackageId] = set() - for i, checker in enumerate(self._checkers): - if not checker.order_dependent: - continue - affected = checker.affected_by_moves(ctx, moved) - for pid, mod in self._active_mods.items(): - if affected is not None and pid not in affected: - continue - issues = self._run_checker(checker, mod, ctx) - if issues != results[pid][i]: - results[pid][i] = issues - changed.add(pid) - for pid in changed: - self._diagnostics[pid] = _compose(results[pid]) - - self._emit() - - # ── Query ───────────────────────────────────────────────── - - @property - def active_mods(self) -> dict[PackageId, AboutXmlMod]: - return dict(self._active_mods) - - @property - def graph(self) -> ConstraintGraph: - return self._graph - - @property - def ordered_pids(self) -> list[PackageId]: - return list(self._ordered_pids) - - @property - def community_rules(self) -> dict[PackageId, CommunityRule] | None: - return self._community_rules - - def diagnostics_for(self, uuid: str) -> ModDiagnostics | None: - """Return diagnostics for a given UUID, or None if not active.""" - pid = self._uuid_to_pid.get(uuid) - if pid is None: - return None - return self._diagnostics.get(pid) - - def active_mod_diagnostics(self) -> dict[str, ModDiagnostics]: - """Return diagnostics for all active mods, keyed by UUID.""" - result: dict[str, ModDiagnostics] = {} - for uuid, pid in self._uuid_to_pid.items(): - diag = self._diagnostics.get(pid) - if diag: - result[uuid] = diag - return result - - # ── Private ─────────────────────────────────────────────── - - def _collect_active( - self, mods: dict[str, ListedMod], ordered_uuids: list[str] - ) -> None: - """Populate internal active-mod state from full mod list and UUID ordering.""" - self._all_mods = dict(mods) - self._active_mods = {} - self._uuid_to_pid = {} - self._ordered_pids = [] - - for uuid in ordered_uuids: - mod = mods.get(uuid) - if isinstance(mod, AboutXmlMod): - pid = mod.package_id - if type(pid) is not PackageId: - pid = PackageId(pid) - self._active_mods[pid] = mod - self._uuid_to_pid[uuid] = pid - self._ordered_pids.append(pid) - - def _build_context( - self, - cycles: list[list[PackageId]], - ) -> CheckContext: - return CheckContext( - active_mods=self._active_mods, - ordered_pids=self._ordered_pids, - pid_to_index=self._graph.pid_to_index, - graph=self._graph, - settings=self._settings, - target_version=self._target_version, - no_version_warning=self._no_version_warning, - use_this_instead=self._use_this_instead, - cycles=cycles, - ) - - def _check_all(self, ctx: CheckContext) -> None: - """Run every checker on every active mod, caching per-checker results.""" - results: dict[PackageId, list[list[ModIssue]]] = {} - diagnostics: dict[PackageId, ModDiagnostics] = {} - checkers = self._checkers - run = self._run_checker - for pid, mod in self._active_mods.items(): - per_checker = [run(c, mod, ctx) for c in checkers] - results[pid] = per_checker - diagnostics[pid] = _compose(per_checker) - self._checker_results = results - self._diagnostics = diagnostics - - def _check_mod(self, mod: AboutXmlMod, ctx: CheckContext) -> ModDiagnostics: - """Run all registered checkers against a single mod and collect diagnostics.""" - return _compose([self._run_checker(c, mod, ctx) for c in self._checkers]) - - @staticmethod - def _run_checker( - checker: ModIssueChecker, mod: AboutXmlMod, ctx: CheckContext - ) -> list[ModIssue]: - if not checker.should_check(mod, ctx): - return [] - try: - issues = checker.check(mod, ctx) - except Exception: # noqa: BLE001 - one checker must not abort diagnostics - logger.exception( - f"Checker {type(checker).__name__} failed on {mod.package_id}" - ) - return [] - return issues if type(issues) is list else list(issues) - - def _emit(self) -> None: - if self._on_diagnostics_changed is not None: - uuid_diag: dict[str, ModDiagnostics] = {} - for uuid, pid in self._uuid_to_pid.items(): - diag = self._diagnostics.get(pid) - if diag and (diag.has_errors or diag.has_warnings): - uuid_diag[uuid] = diag - self._on_diagnostics_changed(uuid_diag) - - -def _compose(per_checker: list[list[ModIssue]]) -> ModDiagnostics: - issues = [issue for checker_issues in per_checker for issue in checker_issues] - if not issues: - return ModDiagnostics([], []) - return ModDiagnostics( - [issue for issue in issues if issue.severity == "error"], - [issue for issue in issues if issue.severity != "error"], - ) - - -def _same_mods( - a: dict[PackageId, AboutXmlMod], b: dict[PackageId, AboutXmlMod] -) -> bool: - return a.keys() == b.keys() and all(b[pid] is mod for pid, mod in a.items()) diff --git a/src/pxmodrim/core/checker/databases.py b/src/pxmodrim/core/checker/databases.py deleted file mode 100644 index eb6c030..0000000 --- a/src/pxmodrim/core/checker/databases.py +++ /dev/null @@ -1,242 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import time -import zipfile -from collections.abc import Mapping -from pathlib import Path -from typing import TYPE_CHECKING - -import httpx -from loguru import logger - -from pxmodrim.core.checker.models import PackageId, ReplacementInfo -from pxmodrim.core.xml import xml_path_to_json - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - -NO_VERSION_WARNING_URL = ( - "https://github.com/emipa606/NoVersionWarning/archive/refs/heads/main.zip" -) -USE_THIS_INSTEAD_URL = ( - "https://github.com/emipa606/UseThisInstead/archive/refs/heads/main.zip" -) - - -def _age_s(path: Path) -> float | None: - try: - return max(0.0, time.time() - path.stat().st_mtime) - except OSError: - return None - - -class NoVersionWarningService: - """Service to download and cache the NoVersionWarning package-ID list.""" - - __slots__ = ("_cache_dir", "_loaded_stamp", "_pids", "_xml_path") - - def __init__(self, config_service: ConfigService) -> None: - self._cache_dir = config_service.config_dir - self._xml_path = self._cache_dir / "ModIdsToFix.xml" - self._pids: set[PackageId] = set() - self._loaded_stamp: tuple[int, int] | None = None - - async def ensure(self, force: bool = False) -> set[PackageId]: - """Return cached PIDs, or download and cache them if missing or forced.""" - if not force: - stamp = await asyncio.to_thread(_file_stamp, self._xml_path) - if stamp is not None: - if stamp != self._loaded_stamp: - self._pids = await asyncio.to_thread(self._load) - self._loaded_stamp = stamp - return self._pids - return await self._download() - - def cache_age_s(self) -> float | None: - return _age_s(self._xml_path) - - def load_if_exists(self) -> set[PackageId]: - """Load cached PIDs if the local file exists, otherwise return empty set.""" - if self._xml_path.exists(): - self._pids = self._load() - return self._pids - - def _load(self) -> set[PackageId]: - """Parse the cached XML file into a set of PackageIds.""" - try: - data = xml_path_to_json(str(self._xml_path)) - mod_ids = data.get("ModIdsToFix", {}).get("li", []) - if isinstance(mod_ids, str): - mod_ids = [mod_ids] - result: set[PackageId] = {PackageId(mid) for mid in mod_ids} - logger.info(f"Loaded {len(result)} No Version Warning entries") - return result - except (KeyError, TypeError, ValueError) as e: - logger.error(f"Failed to load No Version Warning DB: {e}") - return set() - - async def _download(self) -> set[PackageId]: - """Download, extract, and cache the NoVersionWarning database from GitHub.""" - self._cache_dir.mkdir(parents=True, exist_ok=True) - zip_path = self._cache_dir / "no_version_warning.zip" - extract_dir = self._cache_dir / "no_version_warning_extracted" - - try: - async with ( - httpx.AsyncClient() as client, - client.stream( - "GET", NO_VERSION_WARNING_URL, follow_redirects=True, timeout=30.0 - ) as resp, - ): - fh = await asyncio.to_thread(zip_path.open, "wb") - try: - async for chunk in resp.aiter_bytes(8192): - await asyncio.to_thread(fh.write, chunk) - finally: - await asyncio.to_thread(fh.close) - - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - - for root, _dirs, files in extract_dir.walk(): - for filename in files: - if filename == "ModIdsToFix.xml": - src_path = root / filename - src_path.replace(self._xml_path) - self._pids = self._load() - # Cleanup - _rmtree(zip_path, extract_dir) - return self._pids - - except (OSError, zipfile.BadZipFile, httpx.HTTPError) as e: - logger.warning(f"Failed to download No Version Warning DB: {e}") - - _rmtree(zip_path, extract_dir) - return set() - - -class UseThisInsteadService: - """Service to download and cache the UseThisInstead replacement database.""" - - __slots__ = ("_cache_dir", "_entries", "_json_path", "_loaded_stamp") - - def __init__(self, config_service: ConfigService) -> None: - self._cache_dir = config_service.config_dir - self._json_path = self._cache_dir / "replacements.json.gz" - self._entries: dict[str, ReplacementInfo] = {} - self._loaded_stamp: tuple[int, int] | None = None - - async def ensure(self, force: bool = False) -> Mapping[str, ReplacementInfo]: - """Return cached replacement entries, download if missing or forced.""" - if not force: - stamp = await asyncio.to_thread(_file_stamp, self._json_path) - if stamp is not None: - if stamp != self._loaded_stamp: - self._entries = await asyncio.to_thread(self._load) - self._loaded_stamp = stamp - return self._entries - return await self._download() - - def cache_age_s(self) -> float | None: - return _age_s(self._json_path) - - def load_if_exists(self) -> Mapping[str, ReplacementInfo]: - """Load cached replacement entries if local file exists, else empty dict.""" - if self._json_path.exists(): - self._entries = self._load() - return self._entries - - def _load(self) -> dict[str, ReplacementInfo]: - """Parse cached JSON into a dict of old-Workshop-ID to ReplacementInfo.""" - try: - path = self._json_path - if str(path).endswith(".gz"): - import gzip - - with gzip.open(path, "rt", encoding="utf-8-sig") as f: - raw = json.load(f) - else: - with open(path, encoding="utf-8-sig") as f: - raw = json.load(f) - - rules = raw.get("rules", []) if isinstance(raw, dict) else [] - result: dict[str, ReplacementInfo] = {} - for r in rules: - if isinstance(r, dict) and "oldWorkshopId" in r: - result[str(r["oldWorkshopId"])] = ReplacementInfo( - name=r.get("newName", ""), - author=r.get("newAuthor", ""), - packageid=r.get("newPackageId", ""), - pfid=r.get("newWorkshopId", ""), - supportedversions=r.get("newVersions", []), - source="database", - ) - logger.info(f"Loaded {len(result)} Use This Instead entries") - return result - except Exception as e: # noqa: BLE001 - malformed cache falls back empty - logger.error(f"Failed to load Use This Instead DB: {e}") - return {} - - async def _download(self) -> dict[str, ReplacementInfo]: - """Download, extract, and cache the UseThisInstead database from GitHub.""" - self._cache_dir.mkdir(parents=True, exist_ok=True) - zip_path = self._cache_dir / "use_this_instead.zip" - extract_dir = self._cache_dir / "use_this_instead_extracted" - - try: - async with ( - httpx.AsyncClient() as client, - client.stream( - "GET", USE_THIS_INSTEAD_URL, follow_redirects=True, timeout=30.0 - ) as resp, - ): - fh = await asyncio.to_thread(zip_path.open, "wb") - try: - async for chunk in resp.aiter_bytes(8192): - await asyncio.to_thread(fh.write, chunk) - finally: - await asyncio.to_thread(fh.close) - - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - - for root, _dirs, files in extract_dir.walk(): - for filename in files: - if filename == "replacements.json.gz": - src_path = root / filename - src_path.replace(self._json_path) - self._entries = self._load() - _rmtree(zip_path, extract_dir) - return self._entries - - except (OSError, zipfile.BadZipFile, httpx.HTTPError) as e: - logger.warning(f"Failed to download Use This Instead DB: {e}") - - _rmtree(zip_path, extract_dir) - return {} - - -def _file_stamp(path: Path) -> tuple[int, int] | None: - """(mtime_ns, size) of *path*, or None when it does not exist.""" - try: - st = path.stat() - except OSError: - return None - return st.st_mtime_ns, st.st_size - - -def _rmtree(zip_path: Path, extract_dir: Path) -> None: - try: - if zip_path.exists(): - zip_path.unlink() - except OSError: - pass - try: - if extract_dir.exists(): - import shutil - - shutil.rmtree(extract_dir) - except OSError: - pass diff --git a/src/pxmodrim/core/checker/graph.py b/src/pxmodrim/core/checker/graph.py deleted file mode 100644 index a8ba8c2..0000000 --- a/src/pxmodrim/core/checker/graph.py +++ /dev/null @@ -1,399 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable, Iterator -from collections.abc import Set as AbstractSet -from enum import IntEnum, auto -from typing import TYPE_CHECKING, NamedTuple - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, -) - -if TYPE_CHECKING: - from pxmodrim.core.sort.community import CommunityRule - from pxmodrim.core.sort.config import SortSettings - -PackageId = CaseInsensitiveStr - - -class EdgeType(IntEnum): - DEPENDENCY = auto() - LOAD_AFTER = auto() - LOAD_BEFORE = auto() - INCOMPATIBILITY = auto() - ALTERNATIVE = auto() - - -class EdgeOrigin(IntEnum): - ABOUT_XML = auto() - COMMUNITY_RULES = auto() - - -class ConstraintEdge(NamedTuple): - # A NamedTuple keeps hashing/equality in C; graph builds create thousands. - source: PackageId - target: PackageId - type: EdgeType - origin: EdgeOrigin - - def __repr__(self) -> str: - return ( - f"ConstraintEdge({self.source} -> {self.target}, " - f"type={self.type.name}, origin={self.origin.name})" - ) - - -# Bypasses NamedTuple's Python-level __new__ on the build hot path. -_new_edge = tuple.__new__ -_EdgesByType = dict[EdgeType, set[ConstraintEdge]] -_NO_EDGES: frozenset[ConstraintEdge] = frozenset() -_NO_TYPED: dict[EdgeType, set[ConstraintEdge]] = {} -_CYCLE_TYPES = (EdgeType.DEPENDENCY, EdgeType.LOAD_AFTER) - - -class ConstraintGraph: - """Directed graph modelling mod dependencies, load order, incompatibilities.""" - - __slots__ = ( - "_cycle_components", - "_incoming", - "_nodes", - "_ordered_pids", - "_outgoing", - "_pid_to_index", - ) - - def __init__(self) -> None: - # Edges are partitioned by type so per-type queries need no filtering. - self._outgoing: dict[PackageId, _EdgesByType] = {} - self._incoming: dict[PackageId, _EdgesByType] = {} - self._nodes: dict[PackageId, None] = {} - self._pid_to_index: dict[PackageId, int] = {} - self._ordered_pids: list[PackageId] = [] - # Cycle membership depends only on edges, so it survives reorders. - self._cycle_components: list[list[PackageId]] | None = None - - # ── Query ───────────────────────────────────────────────── - - @property - def nodes(self) -> AbstractSet[PackageId]: - return self._nodes.keys() - - def has_node(self, pid: PackageId) -> bool: - return pid in self._nodes - - def outgoing(self, pid: PackageId) -> frozenset[ConstraintEdge]: - by_type = self._outgoing.get(pid) - return frozenset().union(*by_type.values()) if by_type else _NO_EDGES - - def incoming(self, pid: PackageId) -> frozenset[ConstraintEdge]: - by_type = self._incoming.get(pid) - return frozenset().union(*by_type.values()) if by_type else _NO_EDGES - - def neighbors(self, pid: PackageId) -> frozenset[PackageId]: - nbrs: set[PackageId] = set() - for edges in self._outgoing.get(pid, _NO_TYPED).values(): - nbrs.update(edge.target for edge in edges) - for edges in self._incoming.get(pid, _NO_TYPED).values(): - nbrs.update(edge.source for edge in edges) - return frozenset(nbrs) - - def edges_of_type( - self, pid: PackageId, edge_type: EdgeType - ) -> AbstractSet[ConstraintEdge]: - """Read-only view of outgoing edges of one type; callers must not mutate.""" - return self._outgoing.get(pid, _NO_TYPED).get(edge_type, _NO_EDGES) - - def incoming_of_type( - self, pid: PackageId, edge_type: EdgeType - ) -> AbstractSet[ConstraintEdge]: - """Read-only view of incoming edges of one type; callers must not mutate.""" - return self._incoming.get(pid, _NO_TYPED).get(edge_type, _NO_EDGES) - - def index_of(self, pid: PackageId) -> int: - return self._pid_to_index.get(pid, -1) - - @property - def pid_to_index(self) -> dict[PackageId, int]: - return dict(self._pid_to_index) - - def ordered_pids(self) -> list[PackageId]: - return list(self._ordered_pids) - - # ── Build ────────────────────────────────────────────────── - - def build( - self, - active_mods: dict[PackageId, AboutXmlMod], - ordered_pids: list[PackageId], - settings: SortSettings, - community_rules: dict[PackageId, CommunityRule] | None = None, - ) -> None: - """Build constraint graph from active mods, order, settings, community rules.""" - self._outgoing = {} - self._incoming = {} - self._cycle_components = None - self._ordered_pids = list(ordered_pids) - self._pid_to_index = {pid: i for i, pid in enumerate(ordered_pids)} - - # Pre-populate nodes so empty mods are tracked - self._nodes = dict.fromkeys(ordered_pids) - - alt_map = ( - _build_alt_map(active_mods) if settings.use_alternative_package_ids else {} - ) - - for pid, mod in active_mods.items(): - self._add_rules(pid, mod.about_rules, EdgeOrigin.ABOUT_XML, alt_map) - - if settings.use_community_rules and community_rules: - for pid in active_mods: - if pid in community_rules: - cr = community_rules[pid] - self._add_load_rules( - pid, - cr.load_before, - cr.load_after, - cr.incompatible_with, - EdgeOrigin.COMMUNITY_RULES, - alt_map, - ) - - # ── Incremental ops ──────────────────────────────────────── - - def add_mod( - self, - pid: PackageId, - mod: AboutXmlMod, - index: int, - settings: SortSettings, - community_rules: dict[PackageId, CommunityRule] | None = None, - ) -> None: - """Insert a single mod into the graph at a given index and add its edges.""" - self._cycle_components = None - self._ensure_node(pid) - self._ordered_pids.insert(index, pid) - self._rebuild_index() - - alt_map = ( - _build_alt_map({pid: mod}) if settings.use_alternative_package_ids else {} - ) - self._add_rules(pid, mod.about_rules, EdgeOrigin.ABOUT_XML, alt_map) - - if settings.use_community_rules and community_rules and pid in community_rules: - cr = community_rules[pid] - self._add_load_rules( - pid, - cr.load_before, - cr.load_after, - cr.incompatible_with, - EdgeOrigin.COMMUNITY_RULES, - _build_alt_map({pid: mod}), - ) - - def remove_mod(self, pid: PackageId) -> None: - """Remove a mod and all its incident edges from the graph.""" - self._cycle_components = None - for edges in self._outgoing.pop(pid, _NO_TYPED).values(): - for edge in edges: - self._incoming[edge.target][edge.type].discard(edge) - for edges in self._incoming.pop(pid, _NO_TYPED).values(): - for edge in edges: - self._outgoing[edge.source][edge.type].discard(edge) - self._nodes.pop(pid, None) - self._pid_to_index.pop(pid, None) - self._ordered_pids = [p for p in self._ordered_pids if p != pid] - self._rebuild_index() - - def update_order(self, ordered_pids: list[PackageId]) -> None: - """Replace the mod ordering without rebuilding constraint edges.""" - self._ordered_pids = list(ordered_pids) - self._pid_to_index = {pid: i for i, pid in enumerate(ordered_pids)} - - # ── Cycle detection ──────────────────────────────────────── - - def find_cycles(self) -> list[list[PackageId]]: - """Return strongly connected components that contain dependency cycles.""" - if self._cycle_components is None: - self._cycle_components = self._find_cycle_components() - index = self._pid_to_index - missing = len(index) - return [ - sorted(component, key=lambda member: index.get(member, missing)) - for component in self._cycle_components - ] - - def _find_cycle_components(self) -> list[list[PackageId]]: - # Nodes without dependency/load-after edges are trivial SCCs that can - # never be part of a cycle, so they are left out of the search. - adjacency: dict[PackageId, list[PackageId]] = {} - for pid, by_type in self._outgoing.items(): - targets: set[PackageId] = set() - for edge_type in _CYCLE_TYPES: - edges = by_type.get(edge_type) - if edges: - targets.update(edge.target for edge in edges) - if targets: - adjacency[pid] = list(targets) - for neighbors in adjacency.values(): - neighbors[:] = [t for t in neighbors if t in adjacency] - - indices: dict[PackageId, int] = {} - lowlinks: dict[PackageId, int] = {} - active_stack: list[PackageId] = [] - on_stack: set[PackageId] = set() - components: list[list[PackageId]] = [] - next_index = 0 - - for root, root_neighbors in adjacency.items(): - if root in indices: - continue - - indices[root] = next_index - lowlinks[root] = next_index - next_index += 1 - active_stack.append(root) - on_stack.add(root) - dfs_stack: list[tuple[PackageId, Iterator[PackageId]]] = [ - (root, iter(root_neighbors)) - ] - - while dfs_stack: - pid, neighbors = dfs_stack[-1] - neighbor = next(neighbors, None) - if neighbor is None: - dfs_stack.pop() - if lowlinks[pid] == indices[pid]: - component: list[PackageId] = [] - while True: - member = active_stack.pop() - on_stack.remove(member) - component.append(member) - if member == pid: - break - if len(component) > 1 or pid in adjacency[pid]: - components.append(component) - if dfs_stack: - parent = dfs_stack[-1][0] - lowlinks[parent] = min(lowlinks[parent], lowlinks[pid]) - continue - - if neighbor not in indices: - indices[neighbor] = next_index - lowlinks[neighbor] = next_index - next_index += 1 - active_stack.append(neighbor) - on_stack.add(neighbor) - dfs_stack.append((neighbor, iter(adjacency[neighbor]))) - elif neighbor in on_stack and indices[neighbor] < lowlinks[pid]: - lowlinks[pid] = indices[neighbor] - - return components - - # ── Private helpers ──────────────────────────────────────── - - def _ensure_node(self, pid: PackageId) -> None: - if pid not in self._nodes: - self._nodes[pid] = None - - def _add_edges( - self, - source: PackageId, - targets: Iterable[PackageId], - type: EdgeType, - origin: EdgeOrigin, - alt_map: dict[PackageId, PackageId] | None, - ) -> None: - # Hot path of build(): one call per rule set rather than per edge. - nodes = self._nodes - incoming = self._incoming - out_edges: set[ConstraintEdge] | None = None - for target in targets: - if alt_map: - target = alt_map.get(target, target) - if out_edges is None: - if source not in nodes: - nodes[source] = None - by_type = self._outgoing.get(source) - if by_type is None: - by_type = self._outgoing[source] = {} - out_edges = by_type.get(type) - if out_edges is None: - out_edges = by_type[type] = set() - if target not in nodes: - nodes[target] = None - edge = _new_edge(ConstraintEdge, (source, target, type, origin)) - out_edges.add(edge) - in_by_type = incoming.get(target) - if in_by_type is None: - incoming[target] = {type: {edge}} - elif (in_edges := in_by_type.get(type)) is None: - in_by_type[type] = {edge} - else: - in_edges.add(edge) - - def _add_rules( - self, - pid: PackageId, - rules: BaseRules, - origin: EdgeOrigin, - alt_map: dict[PackageId, PackageId], - ) -> None: - for dep_id, dep_mod in rules.dependencies.items(): - self._add_edges(pid, (dep_id,), EdgeType.DEPENDENCY, origin, alt_map) - if dep_mod.alternative_package_ids: - self._add_edges( - pid, - dep_mod.alternative_package_ids, - EdgeType.ALTERNATIVE, - origin, - None, - ) - - self._add_load_rules( - pid, - rules.load_before, - rules.load_after, - rules.incompatible_with, - origin, - alt_map, - ) - - def _add_load_rules( - self, - pid: PackageId, - load_before: AbstractSet[PackageId], - load_after: AbstractSet[PackageId], - incompatible_with: AbstractSet[PackageId], - origin: EdgeOrigin, - alt_map: dict[PackageId, PackageId], - ) -> None: - if load_after: - self._add_edges(pid, load_after, EdgeType.LOAD_AFTER, origin, alt_map) - if load_before: - self._add_edges(pid, load_before, EdgeType.LOAD_BEFORE, origin, alt_map) - if incompatible_with: - self._add_edges( - pid, incompatible_with, EdgeType.INCOMPATIBILITY, origin, alt_map - ) - - def _rebuild_index(self) -> None: - self._pid_to_index = {pid: i for i, pid in enumerate(self._ordered_pids)} - - -def _build_alt_map( - active_mods: dict[PackageId, AboutXmlMod], -) -> dict[PackageId, PackageId]: - """Map canonical package IDs to active alternatives that satisfy them.""" - alt_map: dict[PackageId, PackageId] = {} - for mod in active_mods.values(): - for dep_id, dep in mod.about_rules.dependencies.items(): - if dep_id in active_mods: - continue - for alternative_pid in dep.alternative_package_ids: - if alternative_pid in active_mods: - alt_map[dep_id] = alternative_pid - break - return alt_map diff --git a/src/pxmodrim/core/checker/issues.py b/src/pxmodrim/core/checker/issues.py deleted file mode 100644 index f36f275..0000000 --- a/src/pxmodrim/core/checker/issues.py +++ /dev/null @@ -1,315 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Set as AbstractSet - -from pxmodrim.core.checker.graph import EdgeType -from pxmodrim.core.checker.models import CheckContext, ModIssue, PackageId -from pxmodrim.core.models.metadata.structures import AboutXmlMod - - -class ModIssueChecker(ABC): - """Abstract base for individual mod-issue checkers.""" - - category_display_name: str = "" - # Whether results depend on load order; order-independent results are - # reused when only the order changes. - order_dependent: bool = True - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - return True - - def affected_by_moves( - self, ctx: CheckContext, moved: AbstractSet[PackageId] - ) -> AbstractSet[PackageId] | None: - """Mods whose results may change when `moved` changed index; None means all.""" - return None - - @abstractmethod - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Run checks against a single mod and return any issues found.""" - ... - - -class DependencyIssueChecker(ModIssueChecker): - """Check for missing or unsatisfied mod dependencies.""" - - category_display_name = "Missing Dependency" - order_dependent = False - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - return bool(mod.about_rules.dependencies) - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report errors for unresolved deps, considering alternatives if enabled.""" - if not ctx.settings.check_missing_dependencies: - return [] - - issues: list[ModIssue] = [] - consider_alternatives = ctx.settings.use_alternative_package_ids - - for dep_id, dep_mod in mod.about_rules.dependencies.items(): - satisfied = dep_id in ctx.active_mods - if not satisfied and consider_alternatives: - satisfied = any( - alt in ctx.active_mods for alt in dep_mod.alternative_package_ids - ) - - if not satisfied: - alt_note = "" - if consider_alternatives: - alt_candidates = [str(a) for a in dep_mod.alternative_package_ids] - if alt_candidates: - alt_note = f" (alternatives: {', '.join(alt_candidates)})" - - issues.append( - ModIssue( - category="missing_dependency", - category_display_name=self.category_display_name, - severity="error", - short_message="Missing dependencies", - detail_message=( - f"Dependency not satisfied: {dep_id}{alt_note}" - ), - related_package_ids=(PackageId(dep_id),), - ) - ) - - return issues - - -class IncompatibilityIssueChecker(ModIssueChecker): - """Check for mod-to-mod incompatibility declarations (self-declared and reverse).""" - - category_display_name = "Incompatibility" - order_dependent = False - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - pid = _package_id(mod) - outgoing = ctx.graph.edges_of_type(pid, EdgeType.INCOMPATIBILITY) - incoming = ctx.graph.incoming_of_type(pid, EdgeType.INCOMPATIBILITY) - return ( - bool(mod.about_rules.incompatible_with) or bool(outgoing) or bool(incoming) - ) - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report errors for active incompatible mods without duplicate sources.""" - issues: list[ModIssue] = [] - pid = _package_id(mod) - - # Self-declared incompatibilities - for incomp in mod.about_rules.incompatible_with: - if incomp in ctx.active_mods: - other = ctx.active_mods.get(PackageId(incomp)) - name = other.name if other else str(incomp) - issues.append( - ModIssue( - category="incompatibility", - category_display_name=self.category_display_name, - severity="error", - short_message="Incompatibilities detected", - detail_message=f"Mod declares incompatibility with: " - f"{name} ({incomp})", - related_package_ids=(PackageId(incomp),), - ) - ) - - reported_sources = set(mod.about_rules.incompatible_with) - for edge in ctx.graph.incoming_of_type(pid, EdgeType.INCOMPATIBILITY): - if ( - edge.source not in ctx.active_mods - or edge.source == pid - or edge.source in reported_sources - ): - continue - reported_sources.add(edge.source) - other = ctx.active_mods[edge.source] - issues.append( - ModIssue( - category="incompatibility", - category_display_name=self.category_display_name, - severity="error", - short_message="Incompatibilities detected", - detail_message=f"Declared incompatible by: " - f"{other.name} ({edge.source})", - related_package_ids=(PackageId(edge.source),), - ) - ) - - return issues - - -class LoadOrderIssueChecker(ModIssueChecker): - """Check that load-before/after constraints are satisfied by current order.""" - - category_display_name = "Load Order" - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - pid = _package_id(mod) - return bool( - ctx.graph.edges_of_type(pid, EdgeType.LOAD_BEFORE) - or ctx.graph.edges_of_type(pid, EdgeType.LOAD_AFTER) - ) - - def affected_by_moves( - self, ctx: CheckContext, moved: AbstractSet[PackageId] - ) -> AbstractSet[PackageId] | None: - # Results only read the indices of the mod itself and its edge targets. - affected = set(moved) - for pid in moved: - for edge_type in (EdgeType.LOAD_BEFORE, EdgeType.LOAD_AFTER): - affected.update( - edge.source for edge in ctx.graph.incoming_of_type(pid, edge_type) - ) - return affected - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report warnings when a load-before or load-after constraint is violated.""" - issues: list[ModIssue] = [] - pid = _package_id(mod) - idx = ctx.pid_to_index.get(pid, -1) - if idx < 0: - return issues - - for edge in ctx.graph.edges_of_type(pid, EdgeType.LOAD_BEFORE): - other_idx = ctx.pid_to_index.get(edge.target, -1) - if other_idx >= 0 and idx >= other_idx: - other = ctx.active_mods.get(edge.target) - name = other.name if other else str(edge.target) - issues.append( - ModIssue( - category="load_order", - category_display_name=self.category_display_name, - severity="warning", - short_message="Load order violated", - detail_message=( - f"Should load before: {name} ({edge.target}), " - f"but appears after (index {idx} vs {other_idx})" - ), - related_package_ids=(PackageId(edge.target),), - ) - ) - - for edge in ctx.graph.edges_of_type(pid, EdgeType.LOAD_AFTER): - other_idx = ctx.pid_to_index.get(edge.target, -1) - if other_idx >= 0 and idx <= other_idx: - other = ctx.active_mods.get(edge.target) - name = other.name if other else str(edge.target) - issues.append( - ModIssue( - category="load_order", - category_display_name=self.category_display_name, - severity="warning", - short_message="Load order violated", - detail_message=( - f"Should load after: {name} ({edge.target}), " - f"but appears before (index {idx} vs {other_idx})" - ), - related_package_ids=(PackageId(edge.target),), - ) - ) - - return issues - - -class CycleIssueChecker(ModIssueChecker): - """Check whether a mod participates in a dependency cycle.""" - - category_display_name = "Cycle" - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report warnings for each cycle the mod belongs to.""" - pid = _package_id(mod) - issues: list[ModIssue] = [] - - for cycle in ctx.cycles: - if pid in cycle: - issues.append( - ModIssue( - category="cycle", - category_display_name=self.category_display_name, - severity="warning", - short_message="Dependency cycle detected", - detail_message=( - "Participates in a dependency cycle involving: " - + ", ".join(str(c) for c in cycle) - ), - related_package_ids=tuple(c for c in cycle if c != pid), - ) - ) - - return issues - - -class GameVersionIssueChecker(ModIssueChecker): - """Check that the mod's supported game versions match the target version.""" - - category_display_name = "Version Mismatch" - order_dependent = False - - def should_check(self, mod: AboutXmlMod, ctx: CheckContext) -> bool: - return bool(mod.supported_versions) - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report a warning if the mod's supported versions lack the target version.""" - if not mod.supported_versions: - return [] - - pid = str(mod.package_id).lower() - if pid in ctx.no_version_warning: - return [] - - target = ctx.target_version.split(".")[:2] - target_major_minor = ".".join(target) - - if target_major_minor not in mod.supported_versions: - return [ - ModIssue( - category="version_mismatch", - category_display_name=self.category_display_name, - severity="warning", - short_message="Version mismatch", - detail_message=( - f"Mod supports {', '.join(sorted(mod.supported_versions))}, " - f"but game is on {target_major_minor}" - ), - ) - ] - - return [] - - -class ReplacementIssueChecker(ModIssueChecker): - """Check whether a mod has a known replacement available.""" - - category_display_name = "Replacement Available" - order_dependent = False - - def check(self, mod: AboutXmlMod, ctx: CheckContext) -> list[ModIssue]: - """Report a warning if the mod's published file ID has a replacement.""" - pid = str(mod.published_file_id) if mod.published_file_id else None - if pid is None: - return [] - - replacement = ctx.use_this_instead.get(pid) if ctx.use_this_instead else None - if replacement is None: - return [] - - return [ - ModIssue( - category="replacement_available", - category_display_name=self.category_display_name, - severity="warning", - short_message="Replacement available", - detail_message=( - f"Recommended replacement: {replacement.name} " - f"({replacement.packageid}) by {replacement.author}" - ), - related_package_ids=(PackageId(replacement.packageid),), - ) - ] - - -def _package_id(mod: AboutXmlMod) -> PackageId: - pid = mod.package_id - return pid if type(pid) is PackageId else PackageId(pid) diff --git a/src/pxmodrim/core/checker/models.py b/src/pxmodrim/core/checker/models.py deleted file mode 100644 index 98249a0..0000000 --- a/src/pxmodrim/core/checker/models.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Literal - -from pxmodrim.core.models.metadata.structures import ( - CaseInsensitiveStr, - ReplacementInfo, -) -from pxmodrim.core.sort.config import SortSettings - -if TYPE_CHECKING: - from pxmodrim.core.checker.graph import ConstraintGraph - from pxmodrim.core.models.metadata.structures import AboutXmlMod - -PackageId = CaseInsensitiveStr - - -@dataclass(frozen=True, slots=True) -class ModIssue: - category: str - severity: Literal["error", "warning"] - short_message: str - category_display_name: str = "" - detail_message: str = "" - related_package_ids: tuple[PackageId, ...] = () - - -@dataclass(slots=True) -class ModDiagnostics: - errors: list[ModIssue] = field(default_factory=list) - warnings: list[ModIssue] = field(default_factory=list) - - @property - def has_errors(self) -> bool: - return bool(self.errors) - - @property - def has_warnings(self) -> bool: - return bool(self.warnings) - - @property - def error_tooltip(self) -> str: - if not self.errors: - return "" - if len(self.errors) == 1: - return self.errors[0].short_message - return f"{len(self.errors)} errors" - - @property - def warning_tooltip(self) -> str: - if not self.warnings: - return "" - if len(self.warnings) == 1: - return self.warnings[0].short_message - return f"{len(self.warnings)} warnings" - - -@dataclass(slots=True) -class CheckContext: - active_mods: dict[PackageId, AboutXmlMod] - ordered_pids: list[PackageId] - pid_to_index: dict[PackageId, int] - graph: ConstraintGraph - settings: SortSettings - target_version: str = "1.5" - no_version_warning: set[PackageId] = field(default_factory=set) - use_this_instead: Mapping[str, ReplacementInfo] = field(default_factory=dict) - cycles: list[list[PackageId]] = field(default_factory=list) diff --git a/src/pxmodrim/core/checker/sort.py b/src/pxmodrim/core/checker/sort.py deleted file mode 100644 index 6e04ad0..0000000 --- a/src/pxmodrim/core/checker/sort.py +++ /dev/null @@ -1,156 +0,0 @@ -from __future__ import annotations - -import heapq -from typing import TYPE_CHECKING - -from pxmodrim.core.checker.graph import ConstraintGraph, EdgeType -from pxmodrim.core.checker.models import PackageId -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.sort.config import SortSettings, Tier, TierConfig -from pxmodrim.core.sort.models import CommunityRule -from pxmodrim.core.sort.tiers import assign_tiers - -if TYPE_CHECKING: - from ttimer import Timer - - -def topological_sort( - active_mods: dict[PackageId, AboutXmlMod], - graph: ConstraintGraph, - settings: SortSettings, - community_rules: dict[PackageId, CommunityRule] | None = None, - timer: Timer | None = None, -) -> list[PackageId]: - """Sort active mods into tiers by dependencies, load-order, and config priority.""" - from ttimer import Timer - - t = timer or Timer() - all_pids = set(active_mods.keys()) - if not all_pids: - return [] - - with t("build_deps"): - deps, rev_deps = _build_deps_and_rev_deps(graph, all_pids) - with t("build_load_first"): - load_first = _build_load_first(community_rules, all_pids, settings) - with t("build_load_last"): - load_last = _build_load_last(community_rules, all_pids, settings) - - with t("assign_tiers"): - tiers = assign_tiers( - all_pids, deps, rev_deps, load_first, load_last, settings.tier_config - ) - - order: list[PackageId] = [] - ordered: set[PackageId] = set() - for tier in (Tier.TIER_0, Tier.TIER_1, Tier.TIER_2, Tier.TIER_3): - tier_pids = { - pid for pid in all_pids if pid not in ordered and tiers.get(pid) == tier - } - if not tier_pids: - continue - - with t(f"kahn_{tier.name.lower()}"): - sub_deps = {pid: deps.get(pid, set()) & tier_pids for pid in tier_pids} - indegree = {pid: len(d) for pid, d in sub_deps.items()} - - rev: dict[PackageId, set[PackageId]] = {} - for pid, ds in sub_deps.items(): - for d in ds: - rev.setdefault(d, set()).add(pid) - - config_priority = _build_config_priority(settings.tier_config) - - def _key(pid: PackageId, config_priority=config_priority) -> tuple: - return (config_priority.get(pid, len(config_priority)), pid.lower()) - - queue = [(_key(pid), pid) for pid, deg in indegree.items() if deg == 0] - heapq.heapify(queue) - - while queue: - _, pid = heapq.heappop(queue) - order.append(pid) - ordered.add(pid) - for dependent in rev.get(pid, set()): - indegree[dependent] -= 1 - if indegree[dependent] == 0: - heapq.heappush(queue, (_key(dependent), dependent)) - - remaining = tier_pids - ordered - if remaining: - order.extend(sorted(remaining, key=_key)) - ordered.update(remaining) - - return order - - -def _build_deps_and_rev_deps( - graph: ConstraintGraph, - all_pids: set[PackageId], -) -> tuple[dict[PackageId, set[PackageId]], dict[PackageId, set[PackageId]]]: - """Extract dependency and reverse-dependency maps from constraint graph edges.""" - deps: dict[PackageId, set[PackageId]] = {pid: set() for pid in all_pids} - rev_deps: dict[PackageId, set[PackageId]] = {pid: set() for pid in all_pids} - - for pid in graph.nodes: - if pid not in all_pids: - continue - - deps_pid = deps[pid] - rev_pid = rev_deps[pid] - for edge in graph.outgoing(pid): - target = edge.target - if target not in all_pids: - continue - if edge.type == EdgeType.LOAD_BEFORE: - deps[target].add(pid) - rev_pid.add(target) - elif edge.type in (EdgeType.LOAD_AFTER, EdgeType.DEPENDENCY): - deps_pid.add(target) - rev_deps[target].add(pid) - - return deps, rev_deps - - -def _build_load_first( - community_rules: dict[PackageId, CommunityRule] | None, - all_pids: set[PackageId], - settings: SortSettings, -) -> set[PackageId]: - """Collect PIDs that should be loaded first from community rules.""" - if not settings.use_community_rules or not community_rules: - return set() - return { - pid - for pid in all_pids - if (rule := community_rules.get(pid)) is not None and rule.load_first - } - - -def _build_load_last( - community_rules: dict[PackageId, CommunityRule] | None, - all_pids: set[PackageId], - settings: SortSettings, -) -> set[PackageId]: - """Collect PIDs that should be loaded last from community rules.""" - if not settings.use_community_rules or not community_rules: - return set() - return { - pid - for pid in all_pids - if (rule := community_rules.get(pid)) is not None and rule.load_last - } - - -def _build_config_priority(tier_config: TierConfig) -> dict[PackageId, int]: - """Build a stable ordering priority map from tier-config package lists.""" - priority: dict[PackageId, int] = {} - for i, pid in enumerate(tier_config.tier_0): - priority[pid] = i - for i, pid in enumerate(tier_config.tier_1): - if pid not in priority: - priority[pid] = len(tier_config.tier_0) + i - for i, pid in enumerate(tier_config.tier_3): - if pid not in priority: - priority[pid] = len(tier_config.tier_0) + len(tier_config.tier_1) + i - return priority diff --git a/src/pxmodrim/core/config.py b/src/pxmodrim/core/config.py deleted file mode 100644 index 1ca6574..0000000 --- a/src/pxmodrim/core/config.py +++ /dev/null @@ -1,455 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import sys -from collections.abc import Callable -from pathlib import Path -from time import time -from typing import Any, TypeVar - -import msgspec -from loguru import logger - -from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, AfterLaunch -from pxmodrim.core.msgspec_hooks import dec_hook, enc_hook -from pxmodrim.core.sort.config import SortSettings, TierConfig - -StructT = TypeVar("StructT", bound=msgspec.Struct) -_JSON_SCHEMA_MARKER = "schema_version" -CURRENT_CONFIG_SCHEMA_VERSION = 1 -_VERSIONED_CONFIG_FILES = frozenset({"config.json", "ui_prefs.json"}) -_JsonMigration = Callable[[dict[str, Any]], None] - - -def _v1_json_migration(data: dict[str, Any]) -> None: - return None - - -_JSON_MIGRATIONS: dict[int, _JsonMigration] = {1: _v1_json_migration} - - -def read_game_version(game_path: str | Path) -> str | None: - """Read game version string from ``Version.txt`` in the game directory. - - Returns the stripped content (e.g. ``"1.6.4871 rev598"``) or ``None`` if - the file is missing or cannot be read. - """ - path = Path(game_path) / "Version.txt" - if not path.exists(): - return None - try: - return path.read_text(encoding="utf-8").strip() - except OSError: - logger.warning("Failed to read game version from {}", path) - return None - - -class PathConfig(msgspec.Struct): - """Paths to the game, mods, workshop, and config directories.""" - - game: str = "" - local: str = "" - workshop: str = "" - config_folder: str = "" - community_rules_file: str = "" - no_version_warning_file: str = "" - use_this_instead_file: str = "" - - -class AppConfig(msgspec.Struct): - """Top-level core config: RimWorld paths and sort settings.""" - - paths: PathConfig = msgspec.field(default_factory=PathConfig) - sort: SortSettings = msgspec.field( - default_factory=lambda: SortSettings(tier_config=TierConfig.default()) - ) - max_snapshots: int = 10 - compact_mod_list: bool = False - log_upload_endpoint: str = "https://paste.rs/" - launch_args: str = "" - launch_wrapper: str = "" - after_launch: AfterLaunch = AfterLaunch.STAY - launch_confirm_errors: bool = True - launch_confirm_unsaved: bool = True - launch_confirm_running: bool = True - - -def _migrate_json(data: dict[str, Any], current: int) -> None: - if current > CURRENT_CONFIG_SCHEMA_VERSION: - raise ValueError( - f"Unsupported JSON schema version {current}; " - f"latest is {CURRENT_CONFIG_SCHEMA_VERSION}" - ) - for target in range(current + 1, CURRENT_CONFIG_SCHEMA_VERSION + 1): - migration = _JSON_MIGRATIONS.get(target) - if migration is None: - raise KeyError(f"Missing JSON migration step for target version {target}") - migration(data) - - -class ConfigService: - """Generic file-based config repository backed by a config directory. - - Provides typed load/save for msgspec structs. Core and UI layer - wrappers live in their respective modules and use this service - instead of calling ``config_dir()`` directly. - """ - - __slots__ = ("_config_dir",) - - def __init__(self, config_dir: Path) -> None: - self._config_dir = config_dir - - @property - def config_dir(self) -> Path: - return self._config_dir - - def load(self, filename: str, struct_type: type[StructT]) -> StructT: - path = self._config_dir / filename - if not path.exists(): - return struct_type() - try: - if filename not in _VERSIONED_CONFIG_FILES: - return msgspec.json.decode( - path.read_bytes(), type=struct_type, dec_hook=dec_hook - ) - - raw = msgspec.json.decode(path.read_bytes(), type=dict[str, Any]) - marker = raw.pop(_JSON_SCHEMA_MARKER, None) - if marker is None: - version = 0 - elif type(marker) is int and marker >= 0: - version = marker - else: - raise msgspec.DecodeError("Invalid JSON schema version") - - if version < CURRENT_CONFIG_SCHEMA_VERSION: - _migrate_json(raw, version) - backup = path.with_suffix(f"{path.suffix}.bak.{int(time())}") - shutil.copy2(path, backup) - raw[_JSON_SCHEMA_MARKER] = CURRENT_CONFIG_SCHEMA_VERSION - self._write(path, raw) - elif version > CURRENT_CONFIG_SCHEMA_VERSION: - _migrate_json(raw, version) - - return msgspec.json.decode( - msgspec.json.encode(raw), type=struct_type, dec_hook=dec_hook - ) - except (OSError, msgspec.DecodeError) as e: - logger.warning(f"Failed to load {filename}: {e}") - return struct_type() - - def save(self, filename: str, data: msgspec.Struct) -> None: - path = self._config_dir / filename - path.parent.mkdir(parents=True, exist_ok=True) - encoded = msgspec.json.encode(data, enc_hook=enc_hook) - if filename in _VERSIONED_CONFIG_FILES: - raw = msgspec.json.decode(encoded, type=dict[str, Any]) - raw[_JSON_SCHEMA_MARKER] = CURRENT_CONFIG_SCHEMA_VERSION - encoded = msgspec.json.encode(raw) - self._write(path, encoded) - - def load_existing( - self, filename: str, struct_type: type[StructT] - ) -> StructT | None: - """Like ``load``, but ``None`` when *filename* does not exist yet.""" - if not (self._config_dir / filename).exists(): - return None - return self.load(filename, struct_type) - - def load_raw(self, filename: str) -> dict[str, Any]: - """The undecoded top-level JSON object in *filename*, or ``{}``.""" - try: - return msgspec.json.decode( - (self._config_dir / filename).read_bytes(), type=dict[str, Any] - ) - except (OSError, msgspec.DecodeError): - return {} - - @staticmethod - def _write(path: Path, data: bytes | dict[str, Any]) -> None: - encoded = msgspec.json.encode(data) if isinstance(data, dict) else data - formatted = msgspec.json.format(encoded, indent=2) - tmp = path.with_suffix(path.suffix + ".tmp") - tmp.write_bytes(formatted) - os.replace(tmp, path) - - -def config_dir() -> Path: - base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") - return Path(base) / "pxmodrim" - - -def config_file_path() -> Path: - return config_dir() / "config.json" - - -def community_rules_file() -> Path: - return config_dir() / "communityRules.json" - - -def load_config(path: Path | None = None) -> AppConfig: - """Load config from a JSON file. Returns defaults if missing or corrupt.""" - path = path or config_file_path() - return ConfigService(path.parent).load(path.name, AppConfig) - - -def save_config(cfg: AppConfig, path: Path | None = None) -> None: - """Serialize and write the core config (paths + sort) to a JSON file.""" - path = path or config_file_path() - ConfigService(path.parent).save(path.name, cfg) - logger.info(f"Config saved to {path}") - - -def _detect_config_folder(steam_root: Path | None) -> str: - """Locate the RimWorld Config folder for the current platform.""" - if sys.platform == "linux": - if steam_root: - proton = ( - steam_root - / "steamapps" - / "compatdata" - / RIMWORLD_STEAM_APP_ID - / "pfx" - / "drive_c" - / "users" - / "steamuser" - / "AppData" - / "LocalLow" - / "Ludeon Studios" - / "RimWorld by Ludeon Studios" - / "Config" - ) - if proton.is_dir(): - logger.debug(f"Detected Proton config folder: {proton}") - return str(proton) - native = ( - Path.home() - / ".config" - / "unity3d" - / "Ludeon Studios" - / "RimWorld by Ludeon Studios" - / "Config" - ) - if native.is_dir(): - logger.debug(f"Detected native config folder: {native}") - return str(native) - elif sys.platform == "darwin": - config = Path.home() / "Library" / "Application Support" / "Rimworld" / "Config" - if config.is_dir(): - logger.debug(f"Detected macOS config folder: {config}") - return str(config) - elif sys.platform == "win32": - config = ( - Path.home() - / "AppData" - / "LocalLow" - / "Ludeon Studios" - / "RimWorld by Ludeon Studios" - / "Config" - ) - if config.is_dir(): - logger.debug(f"Detected Windows config folder: {config}") - return str(config) - return "" - - -def detect_game_paths() -> PathConfig: - """Auto-detect game, local mods, and workshop paths via Steam library folders.""" - result = PathConfig() - steam_id = RIMWORLD_STEAM_APP_ID - found_root: Path | None = None - - if sys.platform == "linux": - candidates = [ - Path.home() / ".steam" / "debian-installation", - Path.home() / ".steam" / "steam", - Path.home() / ".local" / "share" / "Steam", - Path.home() - / ".var" - / "app" - / "com.valvesoftware.Steam" - / ".local" - / "share" - / "Steam", - Path.home() / "snap" / "steam" / "common" / ".local" / "share" / "Steam", - ] - for root in candidates: - if not root.is_dir(): - continue - game = _find_rimworld_in_steam_root(root, steam_id) - if game: - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str(_workshop_path_from_game(game, root, steam_id)) - found_root = root - break - if not result.game: - fallback_root = Path.home() / ".steam" / "steam" - fallback = fallback_root / "steamapps" / "common" / "RimWorld" - if fallback.is_dir(): - result.game = str(fallback) - result.local = str(fallback / "Mods") - result.workshop = str( - fallback_root / "steamapps" / "workshop" / "content" / steam_id - ) - found_root = fallback_root if fallback_root.is_dir() else None - - elif sys.platform == "darwin": - steam_root = Path.home() / "Library" / "Application Support" / "Steam" - found_root = steam_root if steam_root.is_dir() else None - game = _find_rimworld_in_steam_root(steam_root, steam_id) - if game: - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str(_workshop_path_from_game(game, steam_root, steam_id)) - else: - fallback = steam_root / "steamapps" / "common" / "RimWorld" - app_bundle = _find_mac_app(fallback) - if app_bundle: - result.game = str(app_bundle) - result.local = str(fallback / "Mods") - result.workshop = str( - steam_root / "steamapps" / "workshop" / "content" / steam_id - ) - - elif sys.platform == "win32": - result = _detect_windows_paths(steam_id) - - result.config_folder = _detect_config_folder(found_root) - result.community_rules_file = str(community_rules_file()) - - return result - - -def _find_rimworld_in_steam_root(root: Path, steam_id: str) -> Path | None: - """Search a Steam library root for an installed RimWorld directory.""" - game = root / "steamapps" / "common" / "RimWorld" - if game.is_dir(): - return game - vdf_path = root / "config" / "libraryfolders.vdf" - if vdf_path.exists(): - return _vdf_find_rimworld(vdf_path, steam_id) - vdf_path = root / "steamapps" / "libraryfolders.vdf" - if vdf_path.exists(): - return _vdf_find_rimworld(vdf_path, steam_id) - return None - - -def _vdf_find_rimworld(vdf_path: Path, steam_id: str) -> Path | None: - """Parse a libraryfolders.vdf to find RimWorld in alternate Steam library paths.""" - try: - import re - - text = vdf_path.read_text(encoding="utf-8", errors="replace") - - # Simple regex-based VDF parser for libraryfolders.vdf - # Looking for: "libraryfolders" { "0" { "path" "..." "apps" - # { "294100" { ... } } } } - path_pattern = re.compile(r'"path"\s+"(.+?)"') - app_pattern = re.compile(r'"' + re.escape(steam_id) + r'"\s*{') - - current_path = None - - for line in text.splitlines(): - stripped = line.strip() - - # Match library folder path - path_match = path_pattern.search(stripped) - if path_match: - current_path = Path(path_match.group(1)) - continue - - # Match app id entry - if app_pattern.search(stripped) and current_path: - game = current_path / "steamapps" / "common" / "RimWorld" - if game.is_dir(): - return game - - except OSError: - logger.warning("Failed to parse libraryfolders.vdf") - return None - - -def _workshop_path_from_game(game: Path, steam_root: Path, steam_id: str) -> Path: - try: - idx = game.parts.index("common") - base = Path(*game.parts[:idx]) - workshop = base / "workshop" / "content" / steam_id - if workshop.is_dir(): - return workshop - except ValueError: - pass - return steam_root / "steamapps" / "workshop" / "content" / steam_id - - -def _find_mac_app(rimworld_dir: Path) -> Path | None: - if rimworld_dir.is_dir(): - apps = list(rimworld_dir.glob("*.app")) - if apps: - return apps[0] - return None - - -def _detect_windows_paths(steam_id: str) -> PathConfig: - """Detect RimWorld paths on Windows via the Windows registry.""" - result = PathConfig() - import winreg - - for reg_key in [ - r"SOFTWARE\Wow6432Node\Valve\Steam", - r"SOFTWARE\Valve\Steam", - ]: - try: - with winreg.OpenKey( # type: ignore[attr-defined] - winreg.HKEY_LOCAL_MACHINE, # type: ignore[attr-defined] - reg_key, - ) as key: - steam_path = winreg.QueryValueEx( # type: ignore[attr-defined] - key, "InstallPath" - )[0] - steam_root = Path(steam_path) - game = _find_rimworld_in_steam_root(steam_root, steam_id) - if game: - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str( - _workshop_path_from_game(game, steam_root, steam_id) - ) - return result - except OSError: - logger.debug("Unable to inspect Windows Steam registry key {}", reg_key) - fallback = Path("C:/Program Files (x86)/Steam") - game = fallback / "steamapps" / "common" / "RimWorld" - if game.is_dir(): - result.game = str(game) - result.local = str(game / "Mods") - result.workshop = str( - fallback / "steamapps" / "workshop" / "content" / steam_id - ) - return result - - -class AppConfigService: - """Owns AppConfig lifecycle: load and save. - - Follows the Service protocol (setup). - """ - - __slots__ = ("_cfg", "_svc") - - def __init__(self, svc: ConfigService) -> None: - self._svc = svc - self._cfg: AppConfig | None = None - - async def setup(self) -> None: - self._cfg = self._svc.load("config.json", AppConfig) - - def cfg(self) -> AppConfig: - assert self._cfg is not None - return self._cfg - - def save_cfg(self) -> None: - if self._cfg is not None: - self._svc.save("config.json", self._cfg) diff --git a/src/pxmodrim/core/constants.py b/src/pxmodrim/core/constants.py deleted file mode 100644 index ca8483e..0000000 --- a/src/pxmodrim/core/constants.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from enum import IntEnum - -RIMWORLD_STEAM_APP_ID = "294100" - - -class LaunchStrategy(IntEnum): - DIRECT = 0 - STEAM = 1 - - -class AfterLaunch(IntEnum): - STAY = 0 - MINIMIZE = 1 - CLOSE = 2 - - -DEFAULT_MISSING_PACKAGEID = "missing.packageid" - -RIMWORLD_DLC_METADATA: dict[str, dict[str, str]] = { - "294100": { - "packageid": "ludeon.rimworld", - "name": "RimWorld", - "steam_url": "https://store.steampowered.com/app/294100/RimWorld", - "description": "Base game", - }, - "1149640": { - "packageid": "ludeon.rimworld.royalty", - "name": "RimWorld - Royalty", - "steam_url": "https://store.steampowered.com/app/1149640/RimWorld__Royalty", - "description": "DLC #1", - }, - "1392840": { - "packageid": "ludeon.rimworld.ideology", - "name": "RimWorld - Ideology", - "steam_url": "https://store.steampowered.com/app/1392840/RimWorld__Ideology", - "description": "DLC #2", - }, - "1826140": { - "packageid": "ludeon.rimworld.biotech", - "name": "RimWorld - Biotech", - "steam_url": "https://store.steampowered.com/app/1826140/RimWorld__Biotech", - "description": "DLC #3", - }, - "2380740": { - "packageid": "ludeon.rimworld.anomaly", - "name": "RimWorld - Anomaly", - "steam_url": "https://store.steampowered.com/app/2380740/RimWorld__Anomaly/", - "description": "DLC #4", - }, - "3022790": { - "packageid": "ludeon.rimworld.odyssey", - "name": "RimWorld - Odyssey", - "steam_url": "https://store.steampowered.com/app/3022790/RimWorld__Odyssey/", - "description": "DLC #5", - }, -} - -RIMWORLD_PACKAGE_IDS: list[str] = [ - v["packageid"] for v in RIMWORLD_DLC_METADATA.values() -] diff --git a/src/pxmodrim/core/context.py b/src/pxmodrim/core/context.py deleted file mode 100644 index ef88d58..0000000 --- a/src/pxmodrim/core/context.py +++ /dev/null @@ -1,287 +0,0 @@ -from __future__ import annotations - -import os -import time -from concurrent.futures import ThreadPoolExecutor -from typing import TYPE_CHECKING - -from loguru import logger - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.downloads.manager import DownloadManager -from pxmodrim.core.events import Event -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.plugin import PluginRegistry -from pxmodrim.core.structures import CollectionStats - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService, PathConfig - from pxmodrim.core.mod_service import ModService - from pxmodrim.core.plugin import Plugin - from pxmodrim.core.providers.base import BaseModProvider - from pxmodrim.core.services.activation_service import ActivationService - from pxmodrim.core.services.diagnostics_service import DiagnosticsService - from pxmodrim.core.services.game_launcher import GameLauncher - from pxmodrim.core.services.sort_service import SortService - -# Minimum valid version string shape: "X.Y" — reject anything shorter -_VERSION_MIN_PARTS = 2 - - -def _require_not_none[T](value: T | None, name: str) -> T: - if value is None: - raise RuntimeError(f"{name} accessed before initialisation") - return value - - -class CoreContext: - """Centralised application state for mods, config, and game version.""" - - __slots__ = ( - "_activation", - "_active_state_changed", - "_active_uuids", - "_cfg", - "_config_changed", - "_config_service", - "_diagnostics_service", - "_game_launcher", - "_game_version", - "_loaded_active_uuids", - "_mod_service", - "_mods", - "_plugins", - "_pool", - "_providers", - "_sort_service", - ) - - def __init__( - self, cfg: AppConfig, config_service: ConfigService | None = None - ) -> None: - self._cfg = cfg - self._config_service = config_service - self._mods: dict[str, ListedMod] = {} - self._active_uuids: list[str] = [] - self._loaded_active_uuids: tuple[str, ...] = () - self._game_version: str = "Unknown" - self._refresh_game_version() - self._mod_service: ModService | None = None - self._diagnostics_service: DiagnosticsService | None = None - self._sort_service: SortService | None = None - self._game_launcher: GameLauncher | None = None - self._activation: ActivationService | None = None - self._providers: list[BaseModProvider] | None = None - self._pool: ThreadPoolExecutor | None = None - self._plugins = PluginRegistry() - # Always present; stays empty until a downloader plugin registers with it. - self._plugins.register(DownloadManager()) - self._active_state_changed = Event[tuple[str, ...]]() - self._config_changed = Event[AppConfig]() - - def load(self, mods: dict[str, ListedMod], active_uuids: list[str]) -> None: - """Replace all mods and active UUIDs, taking ownership of the data.""" - self._mods = dict(mods) - self._active_uuids = list(active_uuids) - self._loaded_active_uuids = tuple(active_uuids) - - def set_active(self, uuids: list[str]) -> None: - old_set = frozenset(self._active_uuids) - new_set = frozenset(uuids) - if old_set == new_set and self._active_uuids == uuids: - return - set_changed = old_set != new_set - self._active_uuids = list(uuids) - if set_changed: - self.diagnostics_service.rebuild(self._active_uuids) - else: - self.diagnostics_service.reorder(self._active_uuids) - self._active_state_changed.emit(tuple(self._active_uuids)) - - async def auto_sort(self) -> tuple[int, float]: - deps = self.sort_service.resolve_missing_dependencies(set(self._active_uuids)) - if deps: - self._active_uuids.extend(deps) - self.diagnostics_service.rebuild(self._active_uuids) - self._active_state_changed.emit(tuple(self._active_uuids)) - logger.info("auto-sort: enabling {} missing dependencies", len(deps)) - t0 = time.monotonic() - self._active_uuids = await self.sort_service.sort_active_mods() - elapsed_ms = (time.monotonic() - t0) * 1000 - self.diagnostics_service.reorder(self._active_uuids) - self._active_state_changed.emit(tuple(self._active_uuids)) - n = len(self._active_uuids) - logger.info("auto-sort: {} mods sorted in {:.0f}ms", n, elapsed_ms) - return n, elapsed_ms - - def update_config(self, cfg: AppConfig) -> None: - """Replace the live config and refresh derived values (game version).""" - self._cfg = cfg - self._refresh_game_version() - self._config_changed.emit(cfg) - - # ── Game version ────────────────────────────────────────────────────────── - - def _refresh_game_version(self) -> None: - """Read the game version from disk and cache it in `_game_version`.""" - from pxmodrim.core.config import read_game_version - - game = self._cfg.paths.game - if not game: - self._game_version = "Unknown" - return - version = read_game_version(game) - self._game_version = version if version else "Unknown" - - @property - def game_version(self) -> str: - return self._game_version - - @property - def target_version(self) -> str: - parts = self._game_version.split(".") - if len(parts) >= _VERSION_MIN_PARTS: - return f"{parts[0]}.{parts[1]}" - return "Unknown" - - # ── Mods ────────────────────────────────────────────────────────────────── - - @property - def all_mods(self) -> dict[str, ListedMod]: - return dict(self._mods) - - @property - def active_uuids(self) -> list[str]: - return list(self._active_uuids) - - @property - def loaded_active_uuids(self) -> list[str]: - """Active UUIDs as last loaded from disk, unaffected by later edits.""" - return list(self._loaded_active_uuids) - - @property - def active_state_changed(self) -> Event[tuple[str, ...]]: - return self._active_state_changed - - @property - def config(self) -> AppConfig: - return self._cfg - - @property - def config_changed(self) -> Event[AppConfig]: - return self._config_changed - - @property - def has_config_service(self) -> bool: - return self._config_service is not None - - @property - def config_service(self) -> ConfigService: - assert self._config_service is not None - return self._config_service - - def compute_stats(self, active_ids: list[str] | None = None) -> CollectionStats: - """Return total/active/inactive/error counts for the current mod set.""" - ids = active_ids if active_ids is not None else self._active_uuids - stats = CollectionStats() - stats.total = len(self._mods) - stats.active = len(ids) - stats.inactive = stats.total - stats.active - for m in self._mods.values(): - if not m.valid: - stats.errors += 1 - return stats - - # ── Factory ──────────────────────────────────────────────────────────────── - - @classmethod - def create( - cls, cfg: AppConfig, config_service: ConfigService | None = None - ) -> CoreContext: - """Create a fully-initialised context with all core services.""" - from pxmodrim.core.mod_service import ModService - from pxmodrim.core.profiler import profile - from pxmodrim.core.providers import create_providers - from pxmodrim.core.services.activation_service import ActivationService - from pxmodrim.core.services.diagnostics_service import ( - DiagnosticsService, - ) - from pxmodrim.core.services.game_launcher import GameLauncher - from pxmodrim.core.services.sort_service import SortService - - ctx = cls(cfg, config_service) - ctx._pool = ThreadPoolExecutor(max_workers=os.cpu_count()) - with profile("services.create") as t: - with t("create_providers"): - ctx._providers = create_providers(cfg.paths, ctx._pool) - with t("diagnostics_service"): - ctx._diagnostics_service = DiagnosticsService(ctx) - with t("mod_service"): - ctx._mod_service = ModService(ctx, ctx._providers) - with t("sort_service"): - ctx._sort_service = SortService(ctx, ctx._diagnostics_service) - with t("game_launcher"): - ctx._game_launcher = GameLauncher(ctx) - with t("activation"): - ctx._activation = ActivationService(ctx) - return ctx - - # ── Service accessors ────────────────────────────────────────────────────── - - @property - def mod_service(self) -> ModService: - return _require_not_none(self._mod_service, "mod_service") - - @property - def diagnostics_service(self) -> DiagnosticsService: - return _require_not_none(self._diagnostics_service, "diagnostics_service") - - @property - def sort_service(self) -> SortService: - return _require_not_none(self._sort_service, "sort_service") - - @property - def activation(self) -> ActivationService: - return _require_not_none(self._activation, "activation") - - @property - def game_launcher(self) -> GameLauncher: - return _require_not_none(self._game_launcher, "game_launcher") - - async def initialize(self) -> None: - svc = _require_not_none(self._mod_service, "mod_service") - await svc.initialize() - - async def close(self) -> None: - if self._pool is not None: - self._pool.shutdown(wait=True) - svc = self._mod_service - if svc is not None: - await svc.startup_impact.close_connection() - await svc.close() - - def close_sync(self) -> None: - """Synchronous teardown for use after the event loop has stopped.""" - if self._pool is not None: - self._pool.shutdown(wait=True) - svc = self._mod_service - if svc is not None: - svc.startup_impact.close_connection_sync() - svc.close_sync() - - def reset_providers(self, paths: PathConfig) -> None: - """Replace providers when the config changes (e.g. after settings dialog).""" - from pxmodrim.core.providers import create_providers - - svc = _require_not_none(self._mod_service, "mod_service") - self._providers = create_providers(paths, self._pool) - svc.reset_providers(self._providers) - - # ── Plugin system ────────────────────────────────────────────────────────── - - def register_plugin(self, plugin: Plugin) -> None: - self._plugins.register(plugin, self) - - @property - def plugins(self) -> PluginRegistry: - return self._plugins diff --git a/src/pxmodrim/core/downloads/__init__.py b/src/pxmodrim/core/downloads/__init__.py deleted file mode 100644 index 6f66a34..0000000 --- a/src/pxmodrim/core/downloads/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.downloads.downloader import Downloader -from pxmodrim.core.downloads.manager import DownloadManager, download_manager -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) - -__all__ = [ - "DownloadItemStatus", - "DownloadItemTitle", - "DownloadManager", - "DownloadProgress", - "DownloadResult", - "Downloader", - "download_manager", -] diff --git a/src/pxmodrim/core/downloads/downloader.py b/src/pxmodrim/core/downloads/downloader.py deleted file mode 100644 index e8d7fe4..0000000 --- a/src/pxmodrim/core/downloads/downloader.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from abc import ABC, abstractmethod -from collections.abc import Iterable -from typing import TYPE_CHECKING, ClassVar - -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import ListedMod - -MANAGER_NAME = "downloads" - - -class Downloader(Plugin, ABC): - """A source that downloads and updates mods into the local mods folder. - - Registers itself with the ``downloads`` manager in ``setup()``; the UI only - talks to the manager, so a downloader can be added or left out freely. - """ - - dependencies: ClassVar[list[str]] = [MANAGER_NAME] - # Human-readable source name shown in the UI, e.g. "Steam Workshop". - label: ClassVar[str] = "" - - status_message_changed: Event[str] - download_progress: Event[DownloadProgress] - download_item_status_changed: Event[DownloadItemStatus] - download_item_titled: Event[DownloadItemTitle] - # "login" -> "query" (item details) -> "run" (all items resolved) - download_phase_changed: Event[str] - download_finished: Event[DownloadResult] - busy_changed: Event[bool] - batch_started: Event[list[str]] - - def __init__(self) -> None: - self.status_message_changed = Event() - self.download_progress = Event() - self.download_item_status_changed = Event() - self.download_item_titled = Event() - self.download_phase_changed = Event() - self.download_finished = Event() - self.busy_changed = Event() - self.batch_started = Event() - - def setup(self, ctx: CoreContext) -> None: - # Deferred: the manager module imports this one. - from pxmodrim.core.downloads.manager import download_manager - - download_manager(ctx).register(self) - - @property - @abstractmethod - def is_downloading(self) -> bool: ... - - @property - @abstractmethod - def active_ids(self) -> frozenset[str]: - """Ids of the batch currently downloading.""" - - @abstractmethod - def accepts(self, mod_id: str) -> bool: - """Whether *mod_id* belongs to this source.""" - - @abstractmethod - def updatable_id(self, mod: ListedMod) -> str | None: - """Id of *mod* in this source if PxModRim downloaded it from here.""" - - @abstractmethod - def last_synced(self, mod: ListedMod) -> float | None: - """Unix time of the last successful sync of *mod*, if known.""" - - @abstractmethod - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - """Download or update *mod_ids*; per-item failures don't raise.""" - - @abstractmethod - def cancel(self) -> None: ... - - def updatable_ids(self, mods: Iterable[ListedMod]) -> list[str]: - ids = (self.updatable_id(m) for m in mods) - return list(dict.fromkeys(pid for pid in ids if pid is not None)) diff --git a/src/pxmodrim/core/downloads/manager.py b/src/pxmodrim/core/downloads/manager.py deleted file mode 100644 index 615ff3a..0000000 --- a/src/pxmodrim/core/downloads/manager.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from typing import TYPE_CHECKING - -from pxmodrim.core.downloads.downloader import MANAGER_NAME, Downloader -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import ListedMod - - -class DownloadManager(Plugin): - """The one download entry point for the UI; routes to registered downloaders. - - With no downloader registered nothing is updatable and ``available`` is False, - which is how the UI hides every download control. - """ - - name = MANAGER_NAME - - status_message_changed: Event[str] - download_progress: Event[DownloadProgress] - download_item_status_changed: Event[DownloadItemStatus] - download_item_titled: Event[DownloadItemTitle] - download_phase_changed: Event[str] - download_finished: Event[DownloadResult] - busy_changed: Event[bool] - batch_started: Event[list[str]] - - def __init__(self) -> None: - self.status_message_changed = Event() - self.download_progress = Event() - self.download_item_status_changed = Event() - self.download_item_titled = Event() - self.download_phase_changed = Event() - self.download_finished = Event() - self.busy_changed = Event() - self.batch_started = Event() - self._downloaders: list[Downloader] = [] - self._cancelled = False - - def register(self, downloader: Downloader) -> None: - self._downloaders.append(downloader) - downloader.status_message_changed.connect(self.status_message_changed.emit) - downloader.download_progress.connect(self.download_progress.emit) - downloader.download_item_status_changed.connect( - self.download_item_status_changed.emit - ) - downloader.download_item_titled.connect(self.download_item_titled.emit) - downloader.download_phase_changed.connect(self.download_phase_changed.emit) - downloader.download_finished.connect(self.download_finished.emit) - downloader.batch_started.connect(self.batch_started.emit) - downloader.busy_changed.connect(self._on_busy) - - def _on_busy(self, busy: bool) -> None: - if busy: - self._cancelled = False - self.busy_changed.emit(self.is_downloading) - - @property - def downloaders(self) -> tuple[Downloader, ...]: - return tuple(self._downloaders) - - @property - def available(self) -> bool: - return bool(self._downloaders) - - @property - def is_downloading(self) -> bool: - return any(d.is_downloading for d in self._downloaders) - - @property - def active_ids(self) -> frozenset[str]: - return frozenset().union(*(d.active_ids for d in self._downloaders)) - - def source_of(self, mod: ListedMod) -> Downloader | None: - """The downloader that can update *mod*, if any.""" - return next((d for d in self._downloaders if d.updatable_id(mod)), None) - - def updatable_id(self, mod: ListedMod) -> str | None: - source = self.source_of(mod) - return None if source is None else source.updatable_id(mod) - - def updatable_ids(self, mods: Iterable[ListedMod]) -> list[str]: - mods = list(mods) - ids: dict[str, None] = {} - for downloader in self._downloaders: - ids.update(dict.fromkeys(downloader.updatable_ids(mods))) - return list(ids) - - def names_by_id(self, mods: Iterable[ListedMod]) -> dict[str, str]: - """Display names of installed *mods*, keyed by their source id.""" - return { - mod_id: mod.name - for mod in mods - if (mod_id := self.updatable_id(mod)) is not None - } - - def last_synced(self, mod: ListedMod) -> float | None: - source = self.source_of(mod) - return None if source is None else source.last_synced(mod) - - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - """Download *mod_ids*, one downloader after another (single-flight overall).""" - groups: dict[Downloader, list[str]] = {} - for mod_id in dict.fromkeys(mod_ids): - owner = next((d for d in self._downloaders if d.accepts(mod_id)), None) - if owner is None: - raise ValueError(f"No download source handles '{mod_id}'.") - groups.setdefault(owner, []).append(mod_id) - if not groups: - raise ValueError("No mods selected for download.") - if self.is_downloading: - raise RuntimeError("A download is already running.") - - self._cancelled = False - total = DownloadResult(succeeded=[], failed=[]) - for downloader, group in groups.items(): - if self._cancelled: - break - result = await downloader.download_mods(group) - total.succeeded += result.succeeded - total.failed += result.failed - total.changed += result.changed - return total - - @property - def cancelled(self) -> bool: - return self._cancelled - - def cancel(self) -> None: - self._cancelled = True - for downloader in self._downloaders: - if downloader.is_downloading: - downloader.cancel() - - -def download_manager(ctx: CoreContext) -> DownloadManager: - manager = ctx.plugins.get(MANAGER_NAME) - if isinstance(manager, DownloadManager): - return manager - raise RuntimeError(f"'{MANAGER_NAME}' plugin is not registered") diff --git a/src/pxmodrim/core/downloads/steam.py b/src/pxmodrim/core/downloads/steam.py deleted file mode 100644 index 7e4f113..0000000 --- a/src/pxmodrim/core/downloads/steam.py +++ /dev/null @@ -1,499 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import functools -import re -import time -from collections.abc import Awaitable, Callable, Sequence -from os import PathLike -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -import msgspec -import pxsteamdl -from loguru import logger - -from pxmodrim.core.downloads.downloader import Downloader -from pxmodrim.core.downloads.types import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadProgress, - DownloadResult, -) -from pxmodrim.core.plugin_config import PluginConfig - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import ListedMod - -# PxSteamDL runs one shared pool of parallel_items * threads_per_item workers. -MAX_PARALLEL_ITEMS = 8 -MAX_THREADS_PER_ITEM = 16 - -_PROGRESS_LOG_INTERVAL_S = 15.0 -_PROGRESS_EMIT_INTERVAL_S = 0.1 -_SYNC_FILE = "workshop_sync.json" -AUTO_STARTUP_DELAY_S = 45.0 -AUTO_CHECK_INTERVAL_S = 600.0 -_MAX_PUBLISHED_FILE_ID = 2**64 - 1 -_PUBLISHED_FILE_ID_RE = re.compile(r"[0-9]+") - - -class WorkshopClient(Protocol): - def download( - self, - ids: Sequence[int], - root: str | PathLike[str], - *, - parallel_items: int = ..., - threads_per_item: int = ..., - on_progress: Callable[[pxsteamdl.Progress], None] | None = None, - on_resolved: Callable[[pxsteamdl.ItemInfo], None] | None = None, - cancel: pxsteamdl.CancelToken | None = None, - ) -> list[pxsteamdl.Result]: ... - - -ClientOptions = tuple[str | None, int, int] -ClientFactory = Callable[[ClientOptions], Awaitable[WorkshopClient]] - - -async def _login(options: ClientOptions) -> WorkshopClient: - proxy, connect_timeout, stall_timeout = options - return await asyncio.to_thread( - pxsteamdl.Client, - proxy=proxy, - connect_timeout=connect_timeout, - stall_timeout=stall_timeout, - ) - - -def _validate_published_file_ids(publishedfileids: list[str]) -> None: - if any( - not isinstance(pfid, str) - or _PUBLISHED_FILE_ID_RE.fullmatch(pfid) is None - or int(pfid) > _MAX_PUBLISHED_FILE_ID - for pfid in publishedfileids - ): - raise ValueError( - "Published file IDs must be ASCII decimal numbers within uint64." - ) - - -class WorkshopSyncState(msgspec.Struct): - """When each Workshop item was last synced with Steam (unix seconds).""" - - synced: dict[str, float] = msgspec.field(default_factory=dict) - - -class _Batch: - __slots__ = ( - "bytes", - "failed", - "last_emitted", - "last_logged", - "resolved", - "seen", - "started", - "succeeded", - "total", - ) - - def __init__(self, total: int) -> None: - self.total = total - self.started = time.monotonic() - self.last_logged = self.started - self.last_emitted = 0.0 - self.seen = 0 - self.resolved = 0 - self.bytes: dict[str, tuple[int, int]] = {} - self.succeeded: list[str] = [] - self.failed: list[str] = [] - - def changed(self) -> list[str]: - return [pid for pid in self.succeeded if self.bytes.get(pid, (0, 0))[1] > 0] - - def progress(self) -> DownloadProgress: - # pxsteamdl returns results only after the whole batch, so an item that - # reports all its bytes (or 0/0 for an up-to-date copy) is counted as done - # before its Result arrives. - finished = set(self.succeeded) | set(self.failed) - finished.update( - pid for pid, (done, total) in self.bytes.items() if done >= total - ) - return DownloadProgress( - total=self.total, - completed=len(finished), - bytes_done=sum(done for done, _ in self.bytes.values()), - bytes_total=sum(total for _, total in self.bytes.values()), - ) - - -class SteamSettings(msgspec.Struct, frozen=True): - auto_update_hours: int = 0 - parallel_items: int = 8 - threads_per_item: int = 1 - proxy: str = "" - connect_timeout: int = 10 - stall_timeout: int = 30 - - -_LEGACY_SETTINGS = { - f"workshop_{field}": field for field in SteamSettings.__struct_fields__ -} - - -class SteamDownloader(Downloader): - """Downloads Steam Workshop items into the local mods folder via PxSteamDL.""" - - name = "steam_downloader" - label = "Steam Workshop" - - def __init__(self, client_factory: ClientFactory | None = None) -> None: - super().__init__() - self._ctx: CoreContext | None = None - self._client_factory: ClientFactory = client_factory or _login - self._client: WorkshopClient | None = None - self._client_options: ClientOptions | None = None - self._token: pxsteamdl.CancelToken | None = None - self._active_ids: frozenset[str] = frozenset() - self._sync = WorkshopSyncState() - self._auto_task: asyncio.Task[None] | None = None - self._running: asyncio.Future[list[pxsteamdl.Result]] | None = None - self.settings = PluginConfig(None, self.name, SteamSettings) - - def setup(self, ctx: CoreContext) -> None: - super().setup(ctx) - self._ctx = ctx - if ctx.has_config_service: - self.settings = PluginConfig( - ctx.config_service, self.name, SteamSettings, legacy=_LEGACY_SETTINGS - ) - - async def init(self, ctx: CoreContext) -> None: - self._sync = await asyncio.to_thread( - ctx.config_service.load, _SYNC_FILE, WorkshopSyncState - ) - self._auto_task = asyncio.create_task(self._auto_update_loop()) - - async def shutdown(self) -> None: - if self._auto_task is not None: - self._auto_task.cancel() - await asyncio.wait([self._auto_task]) - self._auto_task = None - self.cancel() - running = self._running - if running is not None: - await asyncio.wait([running]) - - @property - def active_ids(self) -> frozenset[str]: - """Published file ids of the batch currently downloading.""" - return self._active_ids - - @property - def is_downloading(self) -> bool: - return self._token is not None - - def accepts(self, mod_id: str) -> bool: - return _PUBLISHED_FILE_ID_RE.fullmatch(mod_id) is not None - - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - """Download items into ``paths.local//``; per-item failures don't raise. - - ``cancel()`` stops the batch: finished items stay, the rest are dropped. - """ - if not mod_ids: - raise ValueError("No mods selected for download.") - _validate_published_file_ids(mod_ids) - if self._ctx is None: - raise RuntimeError("SteamDownloader used before setup()") - local = self._ctx.config.paths.local - if not local: - raise ValueError("Local mods path is not configured.") - if self.is_downloading: - raise RuntimeError("A download is already running.") - - ids = list(dict.fromkeys(mod_ids)) - root = Path(local) - batch = _Batch(len(ids)) - token = pxsteamdl.CancelToken() - self._token = token - self._active_ids = frozenset(ids) - self.busy_changed.emit(True) - self.batch_started.emit(list(ids)) - logger.info( - "[workshop] downloading {} items into {} (first ids: {})", - len(ids), - root, - ids[:5], - ) - try: - await self._run(ids, root, batch, token) - finally: - self._token = None - self._active_ids = frozenset() - self.busy_changed.emit(False) - result = DownloadResult( - succeeded=batch.succeeded, - failed=batch.failed, - changed=batch.changed(), - ) - logger.info( - "[workshop] finished in {:.1f}s: {} ok, {} failed{}", - time.monotonic() - batch.started, - len(result.succeeded), - len(result.failed), - f" (failed ids: {result.failed[:20]})" if result.failed else "", - ) - self._record_synced(result.succeeded) - self.download_finished.emit(result) - return result - - def updatable_id(self, mod: ListedMod) -> str | None: - """Workshop id of *mod* if it is a PxModRim download in ``paths.local``.""" - if self._ctx is None or not self._ctx.config.paths.local: - return None - path = mod.mod_path - if path is None or path.parent != Path(self._ctx.config.paths.local): - return None - pfid = mod.published_file_id - return pfid if pfid == path.name else None - - def last_synced(self, mod: ListedMod) -> float | None: - pid = self.updatable_id(mod) - return self._sync.synced.get(pid) if pid else None - - def stale_ids(self, max_age_s: float, now: float | None = None) -> list[str]: - """Updatable ids never synced or synced over *max_age_s* ago, oldest first.""" - cutoff = (time.time() if now is None else now) - max_age_s - synced = self._sync.synced - if self._ctx is None: - return [] - stale = [ - pid - for pid in self.updatable_ids(self._ctx.all_mods.values()) - if synced.get(pid, 0.0) < cutoff - ] - return sorted(stale, key=lambda pid: synced.get(pid, 0.0)) - - def _record_synced(self, ids: list[str]) -> None: - if not ids or self._ctx is None: - return - now = time.time() - self._sync.synced.update(dict.fromkeys(ids, now)) - try: - self._ctx.config_service.save(_SYNC_FILE, self._sync) - except OSError as exc: - logger.warning("[workshop] cannot save sync times: {}", exc) - - async def _auto_update_loop(self) -> None: - await asyncio.sleep(AUTO_STARTUP_DELAY_S) - while True: - try: - await self._auto_update_once() - except Exception: # noqa: BLE001 - logger.exception("[workshop] auto-update iteration failed") - await asyncio.sleep(AUTO_CHECK_INTERVAL_S) - - async def _auto_update_once(self) -> None: - if self._ctx is None or self.is_downloading: - return - hours = self.settings.value.auto_update_hours - if hours <= 0: - return - ids = self.stale_ids(hours * 3600) - if not ids: - return - logger.info("[workshop] auto-update: {} mods older than {}h", len(ids), hours) - try: - await self.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[workshop] auto-update not started: {}", exc) - - def cancel(self) -> None: - token = self._token - if token is not None: - logger.debug("[workshop] cancel requested") - token.cancel() - - async def _run( - self, - ids: list[str], - root: Path, - batch: _Batch, - token: pxsteamdl.CancelToken, - ) -> None: - try: - await asyncio.to_thread(root.mkdir, parents=True, exist_ok=True) - login_started = time.monotonic() - if self._client is None: - self.download_phase_changed.emit("login") - client = await self._ensure_client() - logger.debug( - "[workshop] steam session ready in {:.1f}s", - time.monotonic() - login_started, - ) - except (RuntimeError, OSError) as exc: - message = ( - f"Steam login failed: {exc}" - if isinstance(exc, RuntimeError) - else f"Cannot create {root}: {exc}" - ) - logger.error("[workshop] {}", message) - self.status_message_changed.emit(message) - for pid in ids: - self._finish_item(batch, pid, error=message) - return - - cfg = self.settings.value - parallel_items = min(MAX_PARALLEL_ITEMS, max(1, cfg.parallel_items)) - threads_per_item = min(MAX_THREADS_PER_ITEM, max(1, cfg.threads_per_item)) - logger.debug( - "[workshop] client.download start: {} items, {} parallel x {} threads", - len(ids), - parallel_items, - threads_per_item, - ) - loop = asyncio.get_running_loop() - - def on_resolved(info: pxsteamdl.ItemInfo) -> None: - # Called on the download() thread. - loop.call_soon_threadsafe( - self._on_resolved, batch, str(info.item_id), info.title - ) - - def on_progress(p: pxsteamdl.Progress) -> None: - loop.call_soon_threadsafe( - self._on_progress, - batch, - str(p.item_id), - p.unpacked_bytes, - p.unpacked_total, - ) - - self.download_phase_changed.emit("query") - running = loop.run_in_executor( - None, - functools.partial( - client.download, - [int(pid) for pid in ids], - root, - parallel_items=parallel_items, - threads_per_item=threads_per_item, - on_progress=on_progress, - on_resolved=on_resolved, - cancel=token, - ), - ) - self._running = running - try: - results = await asyncio.shield(running) - except asyncio.CancelledError: - token.cancel() - while not running.done(): - with contextlib.suppress(asyncio.CancelledError): - await asyncio.wait([running]) - raise - except (RuntimeError, OSError) as exc: - self._client = None - for pid in ids: - self._finish_item(batch, pid, error=str(exc) or type(exc).__name__) - return - finally: - self._running = None - - # A network failure leaves the Steam session suspect; log in again next time. - if any(r.error_kind == pxsteamdl.ErrorKind.NETWORK for r in results): - self._client = None - - # Flush progress callbacks queued before the executor returned. - await asyncio.sleep(0) - by_id = {str(r.item_id): r for r in results} - for pid in ids: - result = by_id.get(pid) - if result is None: - self._finish_item(batch, pid, error="no result") - elif not result.cancelled: - self._finish_item(batch, pid, error=result.error) - - def _wanted_options(self) -> ClientOptions: - cfg = self.settings.value - return ( - cfg.proxy.strip() or None, - max(1, cfg.connect_timeout), - max(1, cfg.stall_timeout), - ) - - async def _ensure_client(self) -> WorkshopClient: - options = self._wanted_options() - if self._client is not None and options != self._client_options: - self._client = None - if self._client is None: - self.status_message_changed.emit("Logging in to Steam\u2026") - self._client = await self._client_factory(options) - self._client_options = options - logger.debug("[workshop] pxsteamdl {} logged in", pxsteamdl.__version__) - return self._client - - def _on_resolved(self, batch: _Batch, pid: str, title: str) -> None: - batch.resolved += 1 - if title: - self.download_item_titled.emit(DownloadItemTitle(pid, title)) - if batch.resolved >= batch.total: - self.download_phase_changed.emit("run") - - def _on_progress(self, batch: _Batch, pid: str, done: int, total: int) -> None: - if pid in batch.succeeded or pid in batch.failed: - return - if pid not in batch.bytes: - batch.seen += 1 - logger.debug( - "[workshop] {} first progress: {}/{} bytes (item {} of {})", - pid, - done, - total, - batch.seen, - batch.total, - ) - batch.bytes[pid] = (done, total) - now = time.monotonic() - # pxsteamdl reports per network chunk (thousands/s); UI needs far less. - if done >= total or now - batch.last_emitted >= _PROGRESS_EMIT_INTERVAL_S: - batch.last_emitted = now - self._emit_item_progress(batch, pid, done, total) - if now - batch.last_logged >= _PROGRESS_LOG_INTERVAL_S: - batch.last_logged = now - progress = batch.progress() - logger.info( - "[workshop] progress: {}/{} items, {:.1f} MB, {:.0f}s elapsed", - progress.completed, - progress.total, - progress.bytes_done / 2**20, - now - batch.started, - ) - - def _emit_item_progress( - self, batch: _Batch, pid: str, done: int, total: int - ) -> None: - self.download_item_status_changed.emit( - DownloadItemStatus( - mod_id=pid, status="downloading", bytes_done=done, bytes_total=total - ) - ) - self.download_progress.emit(batch.progress()) - - def _finish_item(self, batch: _Batch, pid: str, *, error: str) -> None: - _, total = batch.bytes.get(pid, (0, 0)) - if error: - logger.warning("[workshop] {} failed: {}", pid, error) - batch.failed.append(pid) - status = DownloadItemStatus(mod_id=pid, status="error", error=error) - else: - batch.succeeded.append(pid) - batch.bytes[pid] = (total, total) - status = DownloadItemStatus( - mod_id=pid, status="success", bytes_done=total, bytes_total=total - ) - self.download_item_status_changed.emit(status) - self.download_progress.emit(batch.progress()) diff --git a/src/pxmodrim/core/downloads/types.py b/src/pxmodrim/core/downloads/types.py deleted file mode 100644 index 84ecc24..0000000 --- a/src/pxmodrim/core/downloads/types.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import msgspec - - -class DownloadProgress(msgspec.Struct): - total: int - completed: int - bytes_done: int - bytes_total: int - - -class DownloadItemStatus(msgspec.Struct): - mod_id: str - status: str # "downloading" | "success" | "error" - bytes_done: int = 0 - bytes_total: int = 0 - error: str = "" - - -class DownloadItemTitle(msgspec.Struct): - mod_id: str - title: str - - -class DownloadResult(msgspec.Struct): - succeeded: list[str] - failed: list[str] - # Succeeded items that actually transferred data (others were already current). - changed: list[str] = msgspec.field(default_factory=list) diff --git a/src/pxmodrim/core/events.py b/src/pxmodrim/core/events.py deleted file mode 100644 index 661e8ad..0000000 --- a/src/pxmodrim/core/events.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - - -class Event[T]: - __slots__ = ("_handlers",) - - def __init__(self) -> None: - self._handlers: list[Callable[[T], Any]] = [] - - def connect(self, handler: Callable[[T], Any]) -> None: - if handler not in self._handlers: - self._handlers.append(handler) - - def disconnect(self, handler: Callable[[T], Any]) -> None: - self._handlers.remove(handler) - - def emit(self, data: T) -> None: - for handler in self._handlers: - handler(data) diff --git a/src/pxmodrim/core/loading.py b/src/pxmodrim/core/loading.py deleted file mode 100644 index b9f02e6..0000000 --- a/src/pxmodrim/core/loading.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -from dataclasses import dataclass - -from PySide6.QtCore import QObject, Signal - - -@dataclass(slots=True) -class StackFrame: - status: str - current_step: int = 0 - total_steps: int = 100 - - -class LoadingState(QObject): - """Stack-based loading state for a single async operation.""" - - changed = Signal(str, int, int, int) # status, pct, current, total - finished = Signal() - - def __init__(self, parent: QObject | None = None) -> None: - """Initialise with an empty stack and no active task.""" - super().__init__(parent) - self._stack: list[StackFrame] = [] - - @property - def statuses(self) -> tuple[str, ...]: - """Status text of every active frame, outermost first.""" - return tuple(frame.status for frame in self._stack) - - def push(self, status: str, total_steps: int = 100) -> None: - """Start a new subtask, pushing a frame onto the loading stack.""" - self._stack.append(StackFrame(status=status, total_steps=total_steps)) - self._emit() - - def pop(self) -> None: - """Finish the current subtask and restore the parent frame.""" - if self._stack: - self._stack.pop() - if not self._stack: - self.finished.emit() - else: - self._emit() - - def step(self, amount: int = 1) -> None: - """Advance the current task's progress by *amount* steps.""" - if self._stack: - frame = self._stack[-1] - frame.current_step = min(frame.total_steps, frame.current_step + amount) - self._emit() - - def set_total(self, total: int) -> None: - """Update the total step count for the current task.""" - if self._stack: - self._stack[-1].total_steps = max(1, total) - self._emit() - - def set_progress(self, value: int) -> None: - """Set the current task's progress to an absolute *value*.""" - if self._stack: - frame = self._stack[-1] - frame.current_step = max(0, min(frame.total_steps, value)) - self._emit() - - def _emit(self) -> None: - if not self._stack: - return - frame = self._stack[-1] - pct = ( - int((frame.current_step / frame.total_steps) * 100) - if frame.total_steps - else 0 - ) - self.changed.emit(frame.status, pct, frame.current_step, frame.total_steps) - - @contextmanager - def task( - self, status: str, total_steps: int = 100 - ) -> Generator[LoadingState, None, None]: - """Context manager: push a frame on enter, pop on exit (even on exception).""" - self.push(status, total_steps) - try: - yield self - finally: - self.pop() diff --git a/src/pxmodrim/core/migrator.py b/src/pxmodrim/core/migrator.py deleted file mode 100644 index fcc19c0..0000000 --- a/src/pxmodrim/core/migrator.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import shutil -from collections.abc import Awaitable, Callable -from pathlib import Path -from time import time -from typing import Any - -from loguru import logger - -MigrationFn = Callable[[], Awaitable[None]] -OnStepSuccess = Callable[[int], Awaitable[None]] - - -async def ensure_schema( - conn: Any, - *, - schema_sql: str, - schema_version: int, - steps: dict[int, MigrationFn], - backup_path: Path | None = None, - label: str = "schema", -) -> None: - """Stamp, init, or migrate a ``PRAGMA user_version`` schema to ``schema_version``. - - ``steps`` maps every target version to a migration coroutine. A file copy - backup of ``backup_path`` is made before a non-fresh migration. - """ - row = await conn.execute("PRAGMA user_version") - fetched = await row.fetchone() - current = fetched[0] if fetched is not None else 0 - - if current >= schema_version: - return - - async def stamp(v: int) -> None: - await conn.execute(f"PRAGMA user_version = {v}") - - if current == 0: - logger.info("Initializing {} (version {})", label, schema_version) - await conn.executescript(schema_sql) - await stamp(schema_version) - return - - logger.info("Migrating {} {} -> {}", label, current, schema_version) - if backup_path is not None: - await conn.execute("PRAGMA wal_checkpoint(FULL)") - backup = backup_path.with_suffix(f".db.bak.{int(time())}") - shutil.copy2(backup_path, backup) - - targets = range(current + 1, schema_version + 1) - for target in targets: - if target not in steps: - raise KeyError(f"Missing migration step for target version {target}") - - migrator = Migrator({target: steps[target] for target in targets}) - await migrator.migrate(current, on_step=stamp) - - -class Migrator: - __slots__ = ("_latest", "_steps") - - def __init__(self, steps: dict[int, MigrationFn]) -> None: - if not steps: - raise ValueError("steps must not be empty") - self._latest = max(steps.keys()) - self._steps = steps - - async def migrate(self, current: int, on_step: OnStepSuccess | None = None) -> bool: - if current >= self._latest: - return False - - for target in range(current + 1, self._latest + 1): - fn = self._steps.get(target) - if fn is None: - raise KeyError(f"Missing migration step for target version {target}") - await fn() - if on_step is not None: - await on_step(target) - - return True diff --git a/src/pxmodrim/core/mod_service.py b/src/pxmodrim/core/mod_service.py deleted file mode 100644 index a65ee36..0000000 --- a/src/pxmodrim/core/mod_service.py +++ /dev/null @@ -1,265 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path - -from loguru import logger -from ttimer import Timer - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.events import Event -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, - ModsConfig, -) -from pxmodrim.core.mods_config import ( - create_snapshot, - get_snapshots, - parse_mods_config, - restore_snapshot, - write_mods_config, -) -from pxmodrim.core.profiler import adopt -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.services.metadata_cache import ( - MetadataCache, - metadata_cache_path, - normalize_paths, -) -from pxmodrim.core.services.mod_discovery import resolve_active_uuids -from pxmodrim.core.services.startup_impact_service import StartupImpactService -from pxmodrim.core.structures import CollectionStats - - -class ModService: - """Orchestrates mod discovery and lifecycle across a set of providers.""" - - __slots__ = ( - "_ctx", - "_metadata_cache", - "_providers", - "_save_lock", - "_startup_impact_service", - "mods_changed", - ) - - def __init__( - self, - ctx: CoreContext, - providers: list[BaseModProvider], - metadata_cache: MetadataCache | None = None, - ) -> None: - self._ctx = ctx - self._save_lock = asyncio.Lock() - self._providers: dict[str, BaseModProvider] = { - p.provider_id: p for p in providers - } - self._startup_impact_service = StartupImpactService(ctx) - if metadata_cache is not None: - self._metadata_cache = metadata_cache - elif ctx.has_config_service: - self._metadata_cache = MetadataCache( - metadata_cache_path(ctx.config_service.config_dir) - ) - else: - self._metadata_cache = None - self.mods_changed: Event[None] = Event() - - @property - def startup_impact(self) -> StartupImpactService: - return self._startup_impact_service - - def reset_providers(self, providers: list[BaseModProvider]) -> None: - """Replace the current provider set with a new list.""" - self._providers = {p.provider_id: p for p in providers} - - def get_provider(self, provider_id: str) -> BaseModProvider | None: - """Retrieve a provider by its ``provider_id``, or ``None``.""" - return self._providers.get(provider_id) - - @property - def provider_colors(self) -> dict[str, str]: - return {pid: p.color for pid, p in self._providers.items()} - - async def discover( - self, timer: Timer | None = None, force_reparse: bool = False - ) -> None: - """Run all providers' discovery in parallel and load results into context.""" - own = timer is None - if own: - timer = Timer() - with timer("discover"): - async with asyncio.TaskGroup() as tg: - provs = [ - tg.create_task(self._discover_one(p, force_reparse)) - for p in self._providers.values() - ] - all_mods: dict[str, ListedMod] = {} - for coro in provs: - mods, child = coro.result() - adopt(timer, child) - all_mods.update(mods) - with timer("cache_prune"): - if self._metadata_cache is not None: - mod_paths = [ - m.mod_path for m in all_mods.values() if m.mod_path is not None - ] - live_paths = await asyncio.to_thread( - lambda: set(normalize_paths(mod_paths)) - ) - await self._metadata_cache.prune_disappeared(live_paths) - with timer("resolve_active"): - active = resolve_active_uuids( - all_mods, self._ctx.config.paths.config_folder - ) - self._ctx.load(all_mods, active) - if own: - logger.debug("Profile report:\n{}", timer.render()) - - async def _discover_one( - self, p: BaseModProvider, force_reparse: bool = False - ) -> tuple[dict[str, ListedMod], Timer]: - t = Timer() - with t(f"provider.{p.provider_id}"): - mods = await p.discover( - self._ctx.target_version, - timer=t, - metadata_cache=self._metadata_cache, - force_reparse=force_reparse, - ) - return mods, t - - async def _timed(self, label: str, fn) -> Timer: - """Run *fn(timer)* wrapped in *label*, return the populated Timer.""" - t = Timer() - with t(label): - await fn(t) - return t - - async def _run_refresh_pipeline( - self, label: str, force_reparse: bool = False - ) -> None: - """Discover mods, initialize diagnostics and load startup impact.""" - timer = Timer() - with timer(f"mod_service.{label}"): - async with asyncio.TaskGroup() as tg: - d = tg.create_task( - self._timed( - "discover", - lambda t: self.discover(timer=t, force_reparse=force_reparse), - ) - ) - diag = tg.create_task( - self._timed( - "diagnostics", - lambda t: self._ctx.diagnostics_service.initialize(timer=t), - ) - ) - si = tg.create_task( - self._timed( - "startup_impact", - lambda _: self._startup_impact_service.load(), - ) - ) - adopt(timer, d.result()) - adopt(timer, diag.result()) - adopt(timer, si.result()) - self._ctx.diagnostics_service.rebuild(self._ctx.active_uuids, timer=timer) - logger.debug("Profile report:\n{}", timer.render()) - self.mods_changed.emit(None) - - def _snapshot_current_config(self) -> Path | None: - config_folder = self._ctx.config.paths.config_folder - snapshots_dir = self._snapshots_dir() - if not config_folder or snapshots_dir is None: - return None - config_path = Path(config_folder) / "ModsConfig.xml" - if not config_path.is_file(): - return None - return create_snapshot( - config_path, - snapshots_dir, - max_snapshots=self._ctx.config.max_snapshots, - ) - - async def initialize(self) -> None: - """One‑time startup initialisation: discover, init diagnostics, load impact.""" - self._snapshot_current_config() - await self._run_refresh_pipeline("initialize") - - async def reload(self, full: bool = False) -> None: - """Reparse all About.xml files when full is requested.""" - await self._run_refresh_pipeline("reload", force_reparse=full) - - def compute_stats(self, active_ids: list[str] | None = None) -> CollectionStats: - return self._ctx.compute_stats(active_ids) - - def _snapshots_dir(self) -> Path | None: - if not self._ctx.has_config_service: - return None - return self._ctx.config_service.config_dir / "snapshots" - - def get_snapshots(self) -> list[Path]: - """Return available mod-list snapshots, sorted newest first.""" - snapshots_dir = self._snapshots_dir() - return get_snapshots(snapshots_dir) if snapshots_dir is not None else [] - - async def restore_snapshot(self, snapshot_path: Path) -> bool: - """Restore a snapshot to ModsConfig.xml and reload live state.""" - config_folder = self._ctx.config.paths.config_folder - snapshots_dir = self._snapshots_dir() - if not config_folder or snapshots_dir is None: - logger.warning("Cannot restore snapshot: configuration is incomplete") - return False - - restored = restore_snapshot( - snapshot_path, - Path(config_folder) / "ModsConfig.xml", - snapshots_dir, - max_snapshots=self._ctx.config.max_snapshots, - ) - if restored: - await self.reload() - return restored - - async def save_active_layout(self, active_ids: list[str]) -> bool: - """Persist the given active UUIDs as a ModsConfig.xml file.""" - config_folder = self._ctx.config.paths.config_folder - snapshots_dir = self._snapshots_dir() - if not config_folder or snapshots_dir is None: - return False - - mods = self._ctx.all_mods - package_ids: list[CaseInsensitiveStr] = [] - for uuid in active_ids: - mod = mods.get(uuid) - if isinstance(mod, AboutXmlMod): - package_ids.append(mod.package_id) - config_path = Path(config_folder) / "ModsConfig.xml" - target_version = self._ctx.target_version - max_snapshots = self._ctx.config.max_snapshots - - def _write() -> None: - existing = parse_mods_config(config_path) - data = ModsConfig( - version=target_version, - activeMods=package_ids, - knownExpansions=existing.knownExpansions if existing else [], - ) - write_mods_config( - config_path, data, snapshots_dir, max_snapshots=max_snapshots - ) - - async with self._save_lock: - await asyncio.to_thread(_write) - return True - - async def close(self) -> None: - if self._metadata_cache is not None: - await self._metadata_cache.close() - - def close_sync(self) -> None: - if self._metadata_cache is not None: - self._metadata_cache.close_sync() diff --git a/src/pxmodrim/core/models/__init__.py b/src/pxmodrim/core/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/core/models/metadata/__init__.py b/src/pxmodrim/core/models/metadata/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/core/models/metadata/parsing.py b/src/pxmodrim/core/models/metadata/parsing.py deleted file mode 100644 index 23b9844..0000000 --- a/src/pxmodrim/core/models/metadata/parsing.py +++ /dev/null @@ -1,748 +0,0 @@ -from __future__ import annotations - -import traceback -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -import lxml.etree as ET -from loguru import logger - -from pxmodrim.core.constants import ( - DEFAULT_MISSING_PACKAGEID, - RIMWORLD_DLC_METADATA, - RIMWORLD_STEAM_APP_ID, -) -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.utils import find_about_xml -from pxmodrim.core.xml import _text - - -class MalformedDataException(Exception): - """Raised when parsed About.xml data is structurally invalid.""" - - def __init__(self, message: str): - super().__init__(message) - self.message = f"Malformed data: {message}" - - -def value_extractor( - input: dict[str, str] | dict[str, list[str]] | Sequence[str] | str | None, - strip_str: bool = True, -) -> str | list[Any] | dict[str, str] | dict[str, list[str]] | None: - """Normalize XML-parsed values, unwrap single-entry dicts and strip strings.""" - if input is None: - return None - if isinstance(input, str): - return input.strip() if strip_str else input - if isinstance(input, Sequence): - return [value_extractor(item) for item in input] - if isinstance(input, dict): - if len(input) == 1: - return value_extractor(next(iter(input.values()))) - if input.keys() == {"@IgnoreIfNoMatchingField", "#text"}: - return input["#text"] - return input - return input - - -def match_version( - input: dict[str, str] | dict[str, list[str]], - target_version: str, - stop_at_first: bool = True, -) -> tuple[bool, None | list[str] | str]: - """Match a versioned dict entry by major.minor, return value(s) on success.""" - try: - major, minor = target_version.split(".")[:2] - except ValueError: - return False, None - version_key = f"v{major}.{minor}" - - if stop_at_first: - result = input.get(version_key) or input.get(f"{major}.{minor}") - if result is not None: - return True, result - - results = [] - for key, value in input.items(): - if key == version_key: - if stop_at_first: - return True, value - if isinstance(value, list): - results.extend(value) - else: - results.append(value) - - if not results: - return False, None - - return True, results - - -def _set_mod_invalid(mod: ListedMod, message: str) -> ListedMod: - mod.valid = False - logger.warning(message) - return mod - - -def _official_mod_name(package_id: str, name: str) -> str: - if name and name not in {"Unknown Mod Name", package_id}: - return name - if package_id == "ludeon.rimworld": - return "Core" - dlc_appid = _get_dlc_packageid_map().get(package_id) - dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {} - if dlc_meta: - return dlc_meta["name"].removeprefix("RimWorld - ") - return package_id - - -def _parse_basic(mod_data: dict[str, Any], mod: AboutXmlMod) -> AboutXmlMod: - """Populate an AboutXmlMod with basic fields (packageId, name, authors).""" - package_id = value_extractor(mod_data.get("packageId", False)) - if isinstance(package_id, str) and package_id.strip(): - mod.package_id = CaseInsensitiveStr(package_id) - else: - mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID) - logger.warning( - f"packageId missing or invalid: {package_id}. " - f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'." - ) - - steam_app_id = value_extractor(mod_data.get("steamAppId", False)) - if isinstance(steam_app_id, str) and steam_app_id.isdigit(): - mod.steam_app_id = int(steam_app_id) - elif str(mod.package_id) in _get_dlc_packageid_map(): - mod.steam_app_id = int(_get_dlc_packageid_map()[str(mod.package_id)]) - - dlc_appid = _get_dlc_packageid_map().get(str(mod.package_id)) - dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {} - - name = value_extractor(mod_data.get("name", False)) - mod.name = _official_mod_name( - str(mod.package_id), name if isinstance(name, str) else "" - ) - - description = value_extractor(mod_data.get("description", False)) - if isinstance(description, str): - mod.description = description - elif dlc_meta: - mod.description = dlc_meta["description"] - - author = value_extractor(mod_data.get("author", False)) - authors = value_extractor(mod_data.get("authors", False)) - - if isinstance(author, str): - mod.authors.append(author) - - normalized_authors: list[str] = [] - if isinstance(authors, dict) and authors.get("li"): - li = authors.get("li") - if isinstance(li, list): - normalized_authors = [str(a) for a in li if a] - elif isinstance(li, str): - normalized_authors = [li] - elif isinstance(authors, list): - normalized_authors = [str(a) for a in authors if a] - elif isinstance(authors, str): - normalized_authors = [authors] - else: - normalized_authors = [] - - mod.authors.extend(normalized_authors) - - supported_versions = value_extractor(mod_data.get("supportedVersions", False)) - if isinstance(supported_versions, list): - mod.supported_versions = set(supported_versions) - elif isinstance(supported_versions, str): - mod.supported_versions = {supported_versions} - - return mod - - -def _parse_optional( - mod_data: dict[str, Any], - mod: AboutXmlMod, - target_version: str, - prefer_versioned: bool = True, -) -> AboutXmlMod: - """Populate optional AboutXmlMod fields (modVersion, icon, url, rules).""" - mod_version = value_extractor(mod_data.get("modVersion", False)) - if mod_version and isinstance(mod_version, str): - mod.mod_version = mod_version - - mod_icon_path = value_extractor(mod_data.get("modIconPath", False)) - if mod_icon_path and isinstance(mod_icon_path, str): - mod.mod_icon_path = Path(mod_icon_path) - - url = value_extractor(mod_data.get("url", False)) - if url and isinstance(url, str): - mod.url = url - - mod.about_rules = create_base_rules(mod_data, target_version, prefer_versioned) - - dlc_map = _get_dlc_packageid_map() - str_pid = str(mod.package_id) - if str_pid in dlc_map and dlc_map[str_pid] != RIMWORLD_STEAM_APP_ID: - rimworld_pid = CaseInsensitiveStr("ludeon.rimworld") - if rimworld_pid not in mod.about_rules.dependencies: - mod.about_rules.dependencies[rimworld_pid] = DependencyMod( - package_id=rimworld_pid, - name="RimWorld", - workshop_url="https://store.steampowered.com/app/294100/RimWorld", - ) - - descriptions_by_version: bool | dict[str, str] = mod_data.get( - "descriptionsByVersion", False - ) - if isinstance(descriptions_by_version, dict): - _, description = match_version(descriptions_by_version, target_version) - if description and isinstance(description, str): - mod.description = description - - return mod - - -def _match_byversion_raw( - byversion_data: dict[str, Any], - target_version: str, -) -> tuple[bool, Any]: - """Match versioned raw data by major.minor key, returning the first match.""" - try: - major, minor = target_version.split(".")[:2] - except ValueError: - return False, None - - version_key = f"v{major}.{minor}" - - if version_key in byversion_data: - return True, byversion_data[version_key] - if f"{major}.{minor}" in byversion_data: - return True, byversion_data[f"{major}.{minor}"] - - return False, None - - -def create_mod_dependency(input_dict: dict[str, str]) -> DependencyMod: - """Build a DependencyMod from a parsed dependency dictionary.""" - mod = DependencyMod() - package_id = input_dict.get("packageId", False) - if isinstance(package_id, str): - mod.package_id = CaseInsensitiveStr(package_id) - - name = input_dict.get("displayName", False) - if isinstance(name, str): - mod.name = name - - workshop_url = input_dict.get("workshopUrl", False) - if isinstance(workshop_url, str): - mod.workshop_url = workshop_url - - alts = input_dict.get("alternativePackageIds", False) - if isinstance(alts, list): - for a in alts: - if isinstance(a, str) and a.strip(): - mod.alternative_package_ids.add(CaseInsensitiveStr(a)) - - return mod - - -def create_base_rules( - mod_data: dict[str, Any], - target_version: str, - prefer_versioned: bool = True, -) -> BaseRules: - """Build BaseRules from parsed mod_data, resolving versioned deps and load-order.""" - rules = BaseRules() - - mod_dependencies = value_extractor(mod_data.get("modDependencies", [])) - if mod_dependencies is None: - mod_dependencies = [] - mod_dependencies = ( - mod_dependencies if isinstance(mod_dependencies, list) else [mod_dependencies] - ) - - if prefer_versioned: - byversion_deps = mod_data.get("modDependenciesByVersion", {}) - if isinstance(byversion_deps, dict) and byversion_deps: - matched, versioned_deps_raw = _match_byversion_raw( - byversion_deps, target_version - ) - if matched: - versioned_deps = ( - value_extractor(versioned_deps_raw) if versioned_deps_raw else [] - ) - mod_dependencies = ( - versioned_deps - if isinstance(versioned_deps, list) - else [versioned_deps] - if versioned_deps - else [] - ) - - for dependency in mod_dependencies: - if isinstance(dependency, dict): - if not dependency or dependency.get("@isNull") == "True": - continue - deps: dict[str, Any] = {} - for key, value in dependency.items(): - if isinstance(value, str): - deps[key] = value - elif key == "alternativePackageIds" and isinstance(value, dict): - alt_li = value.get("li") - alt_list: list[str] = [] - if isinstance(alt_li, list): - for v in alt_li: - if isinstance(v, str): - alt_list.append(v) - elif ( - isinstance(v, dict) - and "#text" in v - and isinstance(v["#text"], str) - ): - alt_list.append(v["#text"]) - elif isinstance(alt_li, str): - alt_list.append(alt_li) - if alt_list: - deps["alternativePackageIds"] = alt_list - elif isinstance(value, dict) and ( - not value or value.get("@isNull") == "True" - ): - continue - else: - logger.warning(f"Skipping invalid dependency value: {value}.") - - dep = create_mod_dependency(deps) - - if dep.package_id in rules.dependencies: - logger.warning( - f"Duplicate dependency found: {dep.package_id}. Skipping." - ) - else: - rules.dependencies[dep.package_id] = dep - elif dependency: - logger.warning(f"Skipping invalid dependency: {dependency}.") - - def load_operations( - mod_data: dict[str, Any], - key: str, - force_key: str, - target_version: str, - prefer_versioned: bool, - ) -> CaseInsensitiveSet: - load = value_extractor(mod_data.get(key, [])) - if load is None: - load = [] - load = load if isinstance(load, list) else [load] - - if prefer_versioned: - byversion = mod_data.get(f"{key}ByVersion", {}) - if isinstance(byversion, dict) and byversion: - matched, versioned_raw = _match_byversion_raw(byversion, target_version) - if matched: - versioned = value_extractor(versioned_raw) if versioned_raw else [] - load = ( - versioned - if isinstance(versioned, list) - else [versioned] - if versioned - else [] - ) - - force_load = value_extractor(mod_data.get(force_key, [])) - if force_load is None: - force_load = [] - force_load = force_load if isinstance(force_load, list) else [force_load] - load.extend(force_load) - - load = [item for item in load if isinstance(item, (str, CaseInsensitiveStr))] - - return CaseInsensitiveSet(load) - - rules.load_before = load_operations( - mod_data, "loadBefore", "forceLoadBefore", target_version, prefer_versioned - ) - - rules.load_after = load_operations( - mod_data, "loadAfter", "forceLoadAfter", target_version, prefer_versioned - ) - - incompatible_with = value_extractor(mod_data.get("incompatibleWith", [])) - if incompatible_with is None: - incompatible_with = [] - incompatible_with = ( - incompatible_with - if isinstance(incompatible_with, list) - else [incompatible_with] - ) - - if prefer_versioned: - byversion_incompat = mod_data.get("incompatibleWithByVersion", {}) - if isinstance(byversion_incompat, dict) and byversion_incompat: - matched, incompat_raw = _match_byversion_raw( - byversion_incompat, target_version - ) - if matched: - versioned_incompat = ( - value_extractor(incompat_raw) if incompat_raw else [] - ) - incompatible_with = ( - versioned_incompat - if isinstance(versioned_incompat, list) - else [versioned_incompat] - if versioned_incompat - else [] - ) - - incompatible_with = [ - item - for item in incompatible_with - if isinstance(item, (str, CaseInsensitiveStr)) - ] - rules.incompatible_with = CaseInsensitiveSet(incompatible_with) - - return rules - - -def create_about_mod( - mod_data: dict[str, Any], - target_version: str, - prefer_versioned: bool = True, -) -> tuple[bool, AboutXmlMod]: - """Create an AboutXmlMod from parsed XML data. Returns (valid, mod).""" - mod = _parse_basic(mod_data, AboutXmlMod()) - if str(mod.package_id) == DEFAULT_MISSING_PACKAGEID: - _set_mod_invalid(mod, "packageId missing or invalid.") - - if not isinstance(mod, AboutXmlMod): - ruled_mod = AboutXmlMod() - ruled_mod.__dict__ = mod.__dict__ - mod = ruled_mod - - mod = _parse_optional(mod_data, mod, target_version, prefer_versioned) - - return mod.valid, mod - - -def _match_versioned_child( - parent: ET._Element, target_version: str -) -> ET._Element | None: - """Find the versioned child element matching target_version (*.*).""" - try: - major, minor = target_version.split(".")[:2] - except ValueError: - return None - for key in (f"v{major}.{minor}", f"{major}.{minor}"): - found = parent.find(key) - if found is not None: - return found - return None - - -def _dep_from_li(li: ET._Element) -> DependencyMod: - """Parse a
  • element from into a DependencyMod.""" - dep = DependencyMod() - pid = _text(li, "packageId") - if pid: - dep.package_id = CaseInsensitiveStr(pid) - name = _text(li, "displayName") - if name: - dep.name = name - url = _text(li, "workshopUrl") - if url: - dep.workshop_url = url - alt_el = li.find("alternativePackageIds") - if alt_el is not None: - for alt_li in alt_el: - if alt_li.tag == "li" and alt_li.text and alt_li.text.strip(): - dep.alternative_package_ids.add(CaseInsensitiveStr(alt_li.text.strip())) - return dep - - -def _element_value(el: ET._Element | None) -> str | list[str] | None: - """Extract text or
  • children as a string or list of strings.""" - if el is None: - return None - children = [ch for ch in el if ch.tag == "li"] - if children: - return [c.text.strip() for c in children if c.text and c.text.strip()] - if el.text: - text = el.text.strip() - return text or None - return None - - -def _create_about_mod_from_element( - root: ET._Element, - target_version: str, - prefer_versioned: bool = True, -) -> AboutXmlMod: - """Build AboutXmlMod in a single pass over the element children.""" - mod = AboutXmlMod() - rules = BaseRules() - has_package_id = False - - deps_el: ET._Element | None = None - deps_bv: bool = False - load_before_li: list[str] = [] - load_after_li: list[str] = [] - incompat_li: list[str] = [] - force_before_li: list[str] = [] - force_after_li: list[str] = [] - load_before_bv_el: ET._Element | None = None - load_after_bv_el: ET._Element | None = None - incompat_bv_el: ET._Element | None = None - - for child in root: - tag = child.tag - if tag == "packageId": - has_package_id = True - t = _element_value(child) - if isinstance(t, str) and t: - mod.package_id = CaseInsensitiveStr(t) - else: - _set_mod_invalid( - mod, - f"packageId missing or invalid: {t}. " - f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'.", - ) - mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID) - - elif tag == "steamAppId": - t = _element_value(child) - if isinstance(t, str) and t.isdigit(): - mod.steam_app_id = int(t) - - elif tag == "name": - t = _element_value(child) - if isinstance(t, str): - mod.name = t - - elif tag == "description": - t = _element_value(child) - if isinstance(t, str): - mod.description = t - - elif tag == "author": - t = _element_value(child) - if isinstance(t, str): - mod.authors.append(t) - - elif tag == "authors": - t = _element_value(child) - if isinstance(t, list): - mod.authors.extend(t) - - elif tag == "supportedVersions": - t = _element_value(child) - if isinstance(t, list): - mod.supported_versions = set(t) - - elif tag == "modVersion": - t = _element_value(child) - if isinstance(t, str): - mod.mod_version = t - - elif tag == "modIconPath": - t = _element_value(child) - if isinstance(t, str): - mod.mod_icon_path = Path(t) - - elif tag == "url": - t = _element_value(child) - if isinstance(t, str): - mod.url = t - - elif tag == "modDependencies": - if not deps_bv: - deps_el = child - elif tag == "modDependenciesByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - deps_el = matched - deps_bv = True - - elif tag == "loadBefore": - t = _element_value(child) - if isinstance(t, list): - load_before_li = t - elif tag == "loadBeforeByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - load_before_bv_el = matched - - elif tag == "loadAfter": - t = _element_value(child) - if isinstance(t, list): - load_after_li = t - elif tag == "loadAfterByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - load_after_bv_el = matched - - elif tag == "forceLoadBefore": - t = _element_value(child) - if isinstance(t, list): - force_before_li = t - elif tag == "forceLoadAfter": - t = _element_value(child) - if isinstance(t, list): - force_after_li = t - - elif tag == "incompatibleWith": - t = _element_value(child) - if isinstance(t, list): - incompat_li = t - elif tag == "incompatibleWithByVersion": - if prefer_versioned: - matched = _match_versioned_child(child, target_version) - if matched is not None: - incompat_bv_el = matched - - elif tag == "descriptionsByVersion": - matched = _match_versioned_child(child, target_version) - if matched is not None and matched.text and matched.text.strip(): - mod.description = matched.text.strip() - - if not has_package_id: - _set_mod_invalid( - mod, - "packageId missing or invalid. " - f"Assigned sentinel '{DEFAULT_MISSING_PACKAGEID}'.", - ) - mod.package_id = CaseInsensitiveStr(DEFAULT_MISSING_PACKAGEID) - - # Apply versioned overrides - if load_before_bv_el is not None: - t = _element_value(load_before_bv_el) - if isinstance(t, list): - load_before_li = t - if load_after_bv_el is not None: - t = _element_value(load_after_bv_el) - if isinstance(t, list): - load_after_li = t - if incompat_bv_el is not None: - t = _element_value(incompat_bv_el) - if isinstance(t, list): - incompat_li = t - - # Combine force + regular - load_before_li.extend(force_before_li) - load_after_li.extend(force_after_li) - - # Build rules - rules.load_before = CaseInsensitiveSet(load_before_li) - rules.load_after = CaseInsensitiveSet(load_after_li) - rules.incompatible_with = CaseInsensitiveSet(incompat_li) - - # Process dependencies - if deps_el is not None: - for li in deps_el: - if li.tag != "li": - continue - if li.attrib.get("isNull") == "True": - continue - if not li.attrib and len(li) == 0: - continue - dep = _dep_from_li(li) - if dep.package_id in rules.dependencies: - logger.warning( - f"Duplicate dependency found: {dep.package_id}. Skipping." - ) - else: - rules.dependencies[dep.package_id] = dep - - mod.about_rules = rules - - str_pid = str(mod.package_id) - mod.name = _official_mod_name(str_pid, mod.name) - dlc_appid = _get_dlc_packageid_map().get(str_pid) - dlc_meta = RIMWORLD_DLC_METADATA.get(dlc_appid, {}) if dlc_appid else {} - if dlc_meta and not mod.description: - mod.description = dlc_meta["description"] - # DLC -> RimWorld dependency - dlc_map = _get_dlc_packageid_map() - if str_pid in dlc_map and dlc_map[str_pid] != RIMWORLD_STEAM_APP_ID: - rimworld_pid = CaseInsensitiveStr("ludeon.rimworld") - if rimworld_pid not in rules.dependencies: - rules.dependencies[rimworld_pid] = DependencyMod( - package_id=rimworld_pid, - name="RimWorld", - workshop_url="https://store.steampowered.com/app/294100/RimWorld", - ) - - return mod - - -def _create_about_mod_from_xml( - base_path: Path, - mod_xml_path: Path, - target_version: str, - prefer_versioned: bool = True, -) -> tuple[bool, AboutXmlMod]: - """Parse an About.xml file and return a validated AboutXmlMod with its path set.""" - try: - tree = ET.parse(str(mod_xml_path)) - except (OSError, TypeError, ET.XMLSyntaxError): - logger.error(f"Unable to parse {mod_xml_path}: {traceback.format_exc()}") - return False, AboutXmlMod(valid=False, _mod_path=base_path) - - root = tree.getroot() - if root is None: - logger.error(f"Could not parse {mod_xml_path}.") - return False, AboutXmlMod(valid=False) - - mod = _create_about_mod_from_element(root, target_version, prefer_versioned) - mod.mod_path = base_path - try: - mod.mtime = mod_xml_path.stat().st_mtime - except OSError: - mod.mtime = 0.0 - return mod.valid, mod - - -def create_listed_mod_from_path( - path: Path, - target_version: str, - prefer_versioned: bool = True, - about_xml_path: Path | None = None, -) -> tuple[bool, ListedMod]: - """Create a ListedMod from a directory path, parsing About.xml if present.""" - if path.is_dir(): - if about_xml_path is None: - about_xml_path = find_about_xml(path) - - if about_xml_path is not None: - success, about_mod = _create_about_mod_from_xml( - path, about_xml_path, target_version, prefer_versioned - ) - return success, about_mod - - rsc_files = list(path.glob("*.rsc")) - if len(rsc_files) > 1: - logger.warning(f"Multiple .rsc files found in {path}. Aborting.") - return False, ListedMod(valid=False, _mod_path=path) - - if len(rsc_files) == 1: - # Scenario .rsc files - not implemented in detail yet - logger.warning(f"Scenario .rsc files not yet supported: {path}") - return False, ListedMod(valid=False, _mod_path=path) - - logger.warning(f"No About.xml found in directory: {path}") - return False, ListedMod(valid=False, _mod_path=path) - - raise ValueError(f"Path must be a directory: {path}") - - -def _get_dlc_packageid_map() -> dict[str, str]: - return {dlc["packageid"]: appid for appid, dlc in RIMWORLD_DLC_METADATA.items()} diff --git a/src/pxmodrim/core/models/metadata/structures.py b/src/pxmodrim/core/models/metadata/structures.py deleted file mode 100644 index b37b78b..0000000 --- a/src/pxmodrim/core/models/metadata/structures.py +++ /dev/null @@ -1,412 +0,0 @@ -from __future__ import annotations - -import functools -import os -from collections.abc import Iterable, Iterator, MutableSet -from collections.abc import Set as AbstractSet -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Literal, Self -from uuid import uuid4 - -import msgspec - -from pxmodrim.core.constants import RIMWORLD_DLC_METADATA - - -@dataclass(slots=True) -class ReplacementInfo: - """Replacement info for a mod from the database or local data.""" - - name: str - author: str - packageid: str - pfid: str - supportedversions: list[str] - source: str = "database" - - -class CaseInsensitiveStr(str): - """Case-insensitive string that stores the lowered version.""" - - def __new__(cls, pid: str) -> Self: - return super().__new__(cls, pid.lower()) - - -class CaseInsensitiveSet(MutableSet[CaseInsensitiveStr]): - """Mutable set with case-insensitive string membership.""" - - def __init__( - self, - s: Iterable[CaseInsensitiveStr | str] | CaseInsensitiveStr | str | None = (), - ): - if isinstance(s, str): - data = {CaseInsensitiveStr(s)} - elif isinstance(s, Iterable): - data = {CaseInsensitiveStr(i) for i in s} - elif not s: - data = set() - else: - raise TypeError(f"Unsupported type, got {type(s)}") - self._data: set[CaseInsensitiveStr] = data - - def __contains__(self, value: Any) -> bool: - if not isinstance(value, CaseInsensitiveStr) and isinstance(value, str): - value = CaseInsensitiveStr(value) - elif not isinstance(value, CaseInsensitiveStr): - return False - return value in self._data - - def __iter__(self) -> Iterator[CaseInsensitiveStr]: - return iter(self._data) - - def __len__(self) -> int: - return len(self._data) - - def __or__(self, other: AbstractSet[Any]) -> CaseInsensitiveSet: - return CaseInsensitiveSet(self._data | {CaseInsensitiveStr(i) for i in other}) - - def __ror__(self, other: AbstractSet[Any]) -> CaseInsensitiveSet: - return self.__or__(other) - - def __eq__(self, other: object) -> bool: - if isinstance(other, CaseInsensitiveSet): - return self._data == other._data - if isinstance(other, AbstractSet): - return self._data == {CaseInsensitiveStr(i) for i in other} - return False - - def discard(self, value: CaseInsensitiveStr | str) -> None: - if not isinstance(value, CaseInsensitiveStr) and isinstance(value, str): - value = CaseInsensitiveStr(value) - return self._data.discard(value) - - def add(self, value: CaseInsensitiveStr | str) -> None: - if not isinstance(value, CaseInsensitiveStr) and isinstance(value, str): - value = CaseInsensitiveStr(value) - return self._data.add(value) - - def __and__(self, other: AbstractSet[Any]) -> AbstractSet[CaseInsensitiveStr]: - return super().__and__(other) - - -class ModsConfig: - """Parsed ModsConfig.xml data with active mods and known expansions.""" - - __slots__ = ("_activeMods", "_knownExpansions", "version") - - def __init__( - self, - version: str, - activeMods: list[CaseInsensitiveStr], - knownExpansions: list[CaseInsensitiveStr], - ): - self.version = version - self.activeMods = activeMods - self.knownExpansions = knownExpansions - - def clear_active_mods(self) -> None: - self._activeMods.clear() - - def clear_all(self) -> None: - self._activeMods.clear() - self._knownExpansions.clear() - - @property - def activeMods(self) -> list[CaseInsensitiveStr]: - return self._activeMods[:] - - @activeMods.setter - def activeMods(self, value: list[CaseInsensitiveStr] | list[str]) -> None: - if isinstance(value, list): - self._activeMods = [CaseInsensitiveStr(i) for i in value] - else: - raise TypeError(f"Expected list, got {type(value)}") - - @property - def knownExpansions(self) -> list[CaseInsensitiveStr]: - return self._knownExpansions[:] - - @knownExpansions.setter - def knownExpansions(self, value: list[CaseInsensitiveStr] | list[str]) -> None: - if isinstance(value, list): - self._knownExpansions = [CaseInsensitiveStr(i) for i in value] - else: - raise TypeError(f"Expected list, got {type(value)}") - - def check_active_duplicates(self) -> bool: - """True if the activeMods list contains duplicate entries.""" - return len(self.activeMods) != len(set(self.activeMods)) - - def check_expansions_duplicates(self) -> bool: - """True if the knownExpansions list contains duplicate entries.""" - return len(self.knownExpansions) != len(set(self.knownExpansions)) - - def to_dict(self) -> dict[str, Any]: - """Serialize ModsConfig to a plain dictionary matching XML li structure.""" - return { - "version": self.version, - "activeMods": {"li": [str(i) for i in self.activeMods]}, - "knownExpansions": {"li": [str(i) for i in self.knownExpansions]}, - } - - -@dataclass -class BaseMod: - """Base dataclass for all mod types with a name and UUID.""" - - name: str = "Unknown Mod Name" - _uuid: str = field(default_factory=lambda: str(uuid4())) - - @property - def uuid(self) -> str: - return self._uuid - - def __hash__(self) -> int: - return hash(self.uuid) - - -@dataclass -class PackageIdMod: - """Mixin dataclass adding a case-insensitive package ID.""" - - package_id: CaseInsensitiveStr = field( - default_factory=lambda: CaseInsensitiveStr("invalid.mod") - ) - - -@dataclass(slots=True) -class DependencyMod(BaseMod, PackageIdMod): - """A mod dependency with workshop URL and alternative package IDs.""" - - workshop_url: str = "" - alternative_package_ids: set[CaseInsensitiveStr] = field(default_factory=set) - - -@dataclass(slots=True) -class ListedMod(BaseMod): - """A mod discovered from any provider with validation and path info.""" - - valid: bool = True - supported_versions: set[str] = field(default_factory=set) - description: str = ( - "This mod is considered invalid by PxModRim (and the RimWorld game)." - + "\n\nThis mod does NOT contain an ./About/About.xml and is likely " - "leftover from previous usage." - ) - - _mod_path: Path | None = None - provider_id: str = "" - obsolete: bool = False - db_builder_no_name: bool = False - - @property - def mod_path(self) -> Path | None: - return self._mod_path - - @mod_path.setter - def mod_path(self, path: Path) -> None: - if self._mod_path: - raise ValueError("Mod path already set. Cannot override.") - self._mod_path = path - # hasattr() would compute the cached_property (disk I/O) just to discard it. - self.__dict__.pop("published_file_id", None) - - @property - def mod_folder(self) -> str | None: - """The folder name (stem) of the mod path, or None.""" - if self.mod_path: - return self.mod_path.stem - return None - - @property - def internal_time_touched(self) -> int: - """Modification timestamp of the mod folder, or -1 if unavailable.""" - if self.mod_path and os.path.exists(self.mod_path): - return int(os.path.getmtime(self.mod_path)) - return -1 - - @property - def uuid(self) -> str: - if self._mod_path: - return str(self._mod_path) - return self._uuid - - @functools.cached_property - def published_file_id(self) -> str | None: - """Published file ID from About/PublishedFileId.txt or numeric folder name.""" - if self.mod_path is None: - return None - pfid_path = self.mod_path / "About/PublishedFileId.txt" - if pfid_path.exists(): - try: - content = pfid_path.read_text(encoding="utf-8-sig").strip() - except OSError: - return None - if content.isdigit() and content: - return content - if self.mod_folder is not None and self.mod_folder.isdigit(): - return self.mod_folder - return None - - @functools.cached_property - def c_sharp_mod(self) -> bool: - """Whether the mod contains a non-empty Assemblies directory.""" - if self.mod_path is None: - return False - assemblies = self.mod_path / "Assemblies" - return assemblies.is_dir() and any(True for _ in assemblies.iterdir()) - - @functools.cached_property - def xml_patch_mod(self) -> bool: - """Whether the mod contains XML patch files in its Patches directory.""" - if self.mod_path is None: - return False - patches = self.mod_path / "Patches" - if not patches.is_dir(): - return False - return any(p.suffix == ".xml" for p in patches.iterdir()) - - @property - def preview_img_path(self) -> Path | None: - """Path to About/Preview.png if it exists, otherwise None.""" - if self.mod_path is None: - return None - candidate = self.mod_path / "About/Preview.png" - if candidate.exists(): - return candidate - return None - - -@dataclass(slots=True) -class ScenarioMod(ListedMod): - """A scenario mod with an optional summary.""" - - summary: str = "" - - -@dataclass(slots=True) -class BaseRules: - """Load order rules: load_after, load_before, incompatible_with, dependencies.""" - - load_after: CaseInsensitiveSet = field(default_factory=CaseInsensitiveSet) - load_before: CaseInsensitiveSet = field(default_factory=CaseInsensitiveSet) - incompatible_with: CaseInsensitiveSet = field(default_factory=CaseInsensitiveSet) - dependencies: dict[CaseInsensitiveStr, DependencyMod] = field(default_factory=dict) - - -@dataclass(slots=True) -class AboutXmlMod(ListedMod, PackageIdMod): - """A mod with an About.xml, including authors, version, rules, and DLC metadata.""" - - authors: list[str] = field(default_factory=list) - mod_version: str = "" - mod_icon_path: Path | None = None - steam_app_id: int = -1 - url: str = "" - mtime: float = 0.0 - about_rules: BaseRules = field(default_factory=BaseRules) - - def get_dlc_name(self) -> str | None: - """Return the DLC name if this mod matches a known RimWorld DLC.""" - for meta in RIMWORLD_DLC_METADATA.values(): - if meta["packageid"] == str(self.package_id): - return meta["name"] - return None - - -@dataclass(slots=True) -class CompiledDependencyData: - """Pre-computed dependency graphs and tier classification for sorting.""" - - deps_graph: dict[str, set[str]] = field(default_factory=dict) - rev_deps_graph: dict[str, set[str]] = field(default_factory=dict) - tier_zero_mods: set[str] = field(default_factory=set) - tier_one_mods: set[str] = field(default_factory=set) - tier_three_mods: set[str] = field(default_factory=set) - incompatibilities: dict[str, set[str]] = field(default_factory=dict) - declared_incompatibilities: dict[str, set[str]] = field(default_factory=dict) - - -class SubExternalRule(msgspec.Struct, omit_defaults=True): - """A single external rule entry with name and comment fields.""" - - name: list[str] | str = msgspec.field(default_factory=str) - comment: list[str] | str = msgspec.field(default_factory=str) - - -class SubExternalBoolRule(msgspec.Struct, omit_defaults=True): - """An external boolean rule with an optional comment.""" - - value: bool = False - comment: list[str] | str = msgspec.field(default_factory=str) - - -class ExternalRule(msgspec.Struct, omit_defaults=True): - """Community-sourced load order rule for a single package ID.""" - - loadAfter: dict[str, SubExternalRule] = {} - loadBefore: dict[str, SubExternalRule] = {} - loadTop: SubExternalBoolRule = msgspec.field(default_factory=SubExternalBoolRule) - loadBottom: SubExternalBoolRule = msgspec.field(default_factory=SubExternalBoolRule) - - -class ExternalRulesSchema(msgspec.Struct, omit_defaults=True): - """Top-level schema for community rules, keyed by package ID.""" - - timestamp: int = 0 - rules: dict[str, ExternalRule] = msgspec.field(default_factory=dict) - - -class SteamDbEntryDependency(msgspec.Struct, omit_defaults=True): - """A dependency reference from the Steam database.""" - - name: str = msgspec.field(default_factory=str) - url: str = msgspec.field(default_factory=str) - - -class SteamDbEntryBlacklist(msgspec.Struct, omit_defaults=True): - """Blacklist status with reason comment from the Steam database.""" - - value: bool = False - comment: str = msgspec.field(default_factory=str) - - -class SteamDbEntry(msgspec.Struct, omit_defaults=True): - """A single entry in the Steam Workshop database.""" - - unpublished: bool = False - url: str = msgspec.field(default_factory=str) - packageId: str = msgspec.field(default_factory=str) - gameVersions: list[str | None] | str | None = msgspec.field(default_factory=list) - steamName: str = msgspec.field(default_factory=str) - name: str = msgspec.field(default_factory=str) - authors: list[str] | str | None = msgspec.field(default_factory=str) - dependencies: dict[str, list[str] | SteamDbEntryDependency] = msgspec.field( - default_factory=dict - ) - blacklist: SteamDbEntryBlacklist = msgspec.field( - default_factory=SteamDbEntryBlacklist - ) - tags: list[dict[str, str]] = msgspec.field(default_factory=list) - - -class SteamDbSchema(msgspec.Struct): - """Top-level Steam Workshop database schema with version and entries.""" - - version: int = 0 - database: dict[str, SteamDbEntry] = msgspec.field(default_factory=dict) - - -ModMetadata = dict[str, Any] - - -@dataclass(slots=True) -class WorkshopUpdateResult: - """Result of a workshop mod update operation.""" - - status: Literal["success", "no_workshop_mods", "partial", "failed"] - mods_checked: int - mods_updated: int - failed_pfids: list[str] - errors: list[str] diff --git a/src/pxmodrim/core/models/view/__init__.py b/src/pxmodrim/core/models/view/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/src/pxmodrim/core/models/view/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/src/pxmodrim/core/models/view/diagnostics.py b/src/pxmodrim/core/models/view/diagnostics.py deleted file mode 100644 index 50f1954..0000000 --- a/src/pxmodrim/core/models/view/diagnostics.py +++ /dev/null @@ -1,21 +0,0 @@ -"""UI-agnostic view models for diagnostics data.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class ModDiagnosticsView: - has_errors: bool = False - has_warnings: bool = False - error_tooltip: str = "" - warning_tooltip: str = "" - - -@dataclass(frozen=True, slots=True) -class ModIssueView: - category: str - category_display_name: str - detail: str | None - is_error: bool diff --git a/src/pxmodrim/core/models/view/sidebar.py b/src/pxmodrim/core/models/view/sidebar.py deleted file mode 100644 index 731e2dc..0000000 --- a/src/pxmodrim/core/models/view/sidebar.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Sidebar entry models — shared between services and UI.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod - -PROVIDER_LABELS: dict[str, str] = { - "local": "Local", - "steam": "Steam Workshop", - "downloaded": "Steam Workshop", - "core": "System / Core", -} - - -class SidebarEntry(ABC): - """A filter entry in the sidebar. Knows which mods it represents.""" - - visible_uuids: set[str] - count: int - - def __init__(self) -> None: - self.visible_uuids = set() - self.count = 0 - - @property - @abstractmethod - def label(self) -> str: ... - - def refresh_count(self) -> None: - self.count = len(self.visible_uuids) - - -class AllModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "All" - - -class ActiveModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "Active" - - -class InactiveModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "Inactive" - - -class ErrorModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "With errors" - - -class WarningModsEntry(SidebarEntry): - @property - def label(self) -> str: - return "With warnings" - - -class ProviderModsEntry(SidebarEntry): - def __init__(self, provider_id: str, label: str, uuids: set[str]) -> None: - super().__init__() - self._pid = provider_id - self._label = label - self.visible_uuids = uuids - self.refresh_count() - - @property - def label(self) -> str: - return self._label diff --git a/src/pxmodrim/core/mods_config.py b/src/pxmodrim/core/mods_config.py deleted file mode 100644 index 935d324..0000000 --- a/src/pxmodrim/core/mods_config.py +++ /dev/null @@ -1,215 +0,0 @@ -from __future__ import annotations - -import os -import tempfile -from datetime import UTC, datetime -from pathlib import Path - -import lxml.etree as ET -from loguru import logger - -from pxmodrim.core.constants import RIMWORLD_DLC_METADATA -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr, ModsConfig -from pxmodrim.core.xml import dict_to_etree - - -def _get_dlc_package_ids() -> list[CaseInsensitiveStr]: - return [ - CaseInsensitiveStr(dlc["packageid"]) for dlc in RIMWORLD_DLC_METADATA.values() - ] - - -def _parse_mods_config(data: bytes, source: Path) -> ModsConfig | None: - try: - root = ET.fromstring(data) - except ET.XMLSyntaxError as e: - logger.error(f"Failed to parse ModsConfig.xml at {source}: {e}") - return None - - version_node = root.find("version") - active_mods_node = root.find("activeMods") - if root.tag != "ModsConfigData" or version_node is None or active_mods_node is None: - logger.error(f"Invalid ModsConfig.xml structure at {source}") - return None - - version = (version_node.text or "1.5").strip() - active_mods = [ - CaseInsensitiveStr(text) - for element in active_mods_node.findall("li") - if (text := (element.text or "").strip()) - ] - known_expansions = [ - CaseInsensitiveStr(text) - for element in root.findall("./knownExpansions/li") - if (text := (element.text or "").strip()) - ] - if not known_expansions: - known_expansions = _get_dlc_package_ids() - - return ModsConfig( - version=version, - activeMods=active_mods, - knownExpansions=known_expansions, - ) - - -def parse_mods_config(path: Path) -> ModsConfig | None: - """Parse a ModsConfig.xml file into a structured ModsConfig object.""" - try: - data = path.read_bytes() - except OSError as e: - logger.warning(f"Failed to read ModsConfig.xml at {path}: {e}") - return None - return _parse_mods_config(data, path) - - -def _snapshot_files(snapshots_dir: Path) -> list[Path]: - snapshots = [ - path for path in snapshots_dir.glob("ModsConfig_*.xml") if path.is_file() - ] - snapshots.sort(key=lambda path: (path.stat().st_mtime_ns, path.name), reverse=True) - return snapshots - - -def _atomic_write(path: Path, data: bytes) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - descriptor, temp_name = tempfile.mkstemp( - dir=path.parent, - prefix=f".{path.name}.", - suffix=".tmp", - ) - tmp_path = Path(temp_name) - try: - with os.fdopen(descriptor, "wb") as tmp: - tmp.write(data) - tmp.flush() - os.fsync(tmp.fileno()) - os.replace(tmp_path, path) - finally: - tmp_path.unlink(missing_ok=True) - - -def _prune_snapshots( - snapshots_dir: Path, - max_snapshots: int, - preserved_path: Path | None = None, -) -> None: - snapshots = _snapshot_files(snapshots_dir) - excess = len(snapshots) - max(1, max_snapshots) - removable = [path for path in reversed(snapshots) if path != preserved_path] - for path in removable[: max(0, excess)]: - try: - path.unlink() - except OSError as e: - logger.warning(f"Failed to remove old snapshot {path}: {e}") - - -def create_snapshot( - config_path: Path, - snapshots_dir: Path, - max_snapshots: int = 10, -) -> Path | None: - """Preserve a uniquely named snapshot of an existing ModsConfig.xml file.""" - if not config_path.is_file(): - return None - - try: - config_data = config_path.read_bytes() - except OSError as e: - logger.warning(f"Failed to read {config_path} for snapshot: {e}") - return None - - snapshots = get_snapshots(snapshots_dir) - if snapshots: - try: - if snapshots[0].read_bytes() == config_data: - _prune_snapshots(snapshots_dir, max_snapshots) - return snapshots[0] - except OSError: - pass - - timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S_%f") - snapshot_path = snapshots_dir / f"ModsConfig_{timestamp}.xml" - counter = 1 - while snapshot_path.exists(): - snapshot_path = snapshots_dir / f"ModsConfig_{timestamp}_{counter:03d}.xml" - counter += 1 - - _atomic_write(snapshot_path, config_data) - _prune_snapshots(snapshots_dir, max_snapshots) - logger.info(f"Snapshot created at {snapshot_path}") - return snapshot_path - - -def get_snapshots(snapshots_dir: Path) -> list[Path]: - """Return available snapshot files, sorted newest first.""" - if not snapshots_dir.exists(): - return [] - return _snapshot_files(snapshots_dir) - - -def restore_snapshot( - snapshot_path: Path, - target_path: Path, - snapshots_dir: Path, - max_snapshots: int = 10, -) -> bool: - """Atomically restore a valid snapshot while preserving the current file.""" - try: - snapshot_data = snapshot_path.read_bytes() - except OSError as e: - logger.error(f"Failed to read snapshot {snapshot_path}: {e}") - return False - - if _parse_mods_config(snapshot_data, snapshot_path) is None: - logger.error(f"Snapshot is invalid or corrupted: {snapshot_path}") - return False - - if target_path.exists(): - limit = max(1, max_snapshots) - create_snapshot(target_path, snapshots_dir, max_snapshots=limit + 1) - _prune_snapshots(snapshots_dir, limit, preserved_path=snapshot_path) - _atomic_write(target_path, snapshot_data) - logger.info(f"Restored snapshot {snapshot_path} to {target_path}") - return True - - -def write_mods_config( - path: Path, - data: ModsConfig, - snapshots_dir: Path, - max_snapshots: int = 10, -) -> None: - root: ET._Element | None = None - if path.exists(): - try: - existing_root = ET.parse(str(path)).getroot() - except (OSError, ET.XMLSyntaxError): - existing_root = None - if existing_root is not None and existing_root.tag == "ModsConfigData": - root = existing_root - - if root is None: - root = dict_to_etree({"ModsConfigData": data.to_dict()}) - else: - fields = data.to_dict() - for name in ("version", "activeMods", "knownExpansions"): - node = root.find(name) - if node is None: - node = ET.SubElement(root, name) - value = fields[name] - if name == "version": - node.text = str(value) - continue - for child in node.findall("li"): - node.remove(child) - for item in value["li"]: - ET.SubElement(node, "li").text = item - - formatted = ET.tostring( - root, encoding="utf-8", xml_declaration=True, pretty_print=True - ) - if path.exists(): - create_snapshot(path, snapshots_dir, max_snapshots=max_snapshots) - _atomic_write(path, formatted) - logger.info(f"ModsConfig.xml written to {path}") diff --git a/src/pxmodrim/core/msgspec_hooks.py b/src/pxmodrim/core/msgspec_hooks.py deleted file mode 100644 index 92db58d..0000000 --- a/src/pxmodrim/core/msgspec_hooks.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr - - -def enc_hook(obj: Any) -> Any: - if isinstance(obj, CaseInsensitiveStr): - return str(obj) - raise NotImplementedError( - f"Object of type {type(obj).__name__} is not JSON serializable" - ) - - -def dec_hook(type_: type, obj: Any) -> Any: - if type_ is CaseInsensitiveStr and isinstance(obj, str): - return CaseInsensitiveStr(obj) - raise NotImplementedError(f"Cannot decode {type_}") diff --git a/src/pxmodrim/core/organizer/__init__.py b/src/pxmodrim/core/organizer/__init__.py deleted file mode 100644 index e209a62..0000000 --- a/src/pxmodrim/core/organizer/__init__.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.organizer.db import OrganizerDb -from pxmodrim.core.organizer.defaults import STANDARD_RULES, StandardRule -from pxmodrim.core.organizer.models import ( - MAX_DEPTH, - ROOT_ID, - Folder, - OrganizerError, - OrganizerState, - Rule, - RuleField, - RuleOp, - RuleSpec, - Tag, -) -from pxmodrim.core.organizer.resolve import ( - CheckState, - FolderNode, - ModLeaf, - Placement, - StatusFilter, - TreeFilter, - TreeQuery, - build_tree, - folder_for, - tree_filters, -) -from pxmodrim.core.organizer.service import OrganizerService - -__all__ = [ - "MAX_DEPTH", - "ROOT_ID", - "STANDARD_RULES", - "CheckState", - "Folder", - "FolderNode", - "ModLeaf", - "OrganizerDb", - "OrganizerError", - "OrganizerService", - "OrganizerState", - "Placement", - "Rule", - "RuleField", - "RuleOp", - "RuleSpec", - "StandardRule", - "StatusFilter", - "Tag", - "TreeFilter", - "TreeQuery", - "build_tree", - "folder_for", - "tree_filters", -] diff --git a/src/pxmodrim/core/organizer/db.py b/src/pxmodrim/core/organizer/db.py deleted file mode 100644 index 03734cc..0000000 --- a/src/pxmodrim/core/organizer/db.py +++ /dev/null @@ -1,563 +0,0 @@ -from __future__ import annotations - -import asyncio -import sqlite3 -from collections.abc import Iterable, Sequence -from pathlib import Path - -import aiosqlite - -from pxmodrim.core.migrator import ensure_schema -from pxmodrim.core.organizer.defaults import StandardRule -from pxmodrim.core.organizer.models import ( - ROOT_ID, - Folder, - OrganizerError, - OrganizerState, - Rule, - RuleSpec, - Tag, -) - -_SCHEMA = """ -CREATE TABLE IF NOT EXISTS folders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - parent_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, - name TEXT NOT NULL, - collapsed INTEGER NOT NULL DEFAULT 0, - CHECK ((id = 1) = (parent_id IS NULL)) -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_parent_name - ON folders (parent_id, name COLLATE NOCASE); - -CREATE TABLE IF NOT EXISTS placements ( - package_id TEXT PRIMARY KEY COLLATE NOCASE, - folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL UNIQUE COLLATE NOCASE, - color TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS mod_tags ( - package_id TEXT NOT NULL COLLATE NOCASE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (package_id, tag_id) -); - -CREATE TABLE IF NOT EXISTS rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - position INTEGER NOT NULL, - field TEXT NOT NULL CHECK (field IN ('package_id', 'name', 'author')), - op TEXT NOT NULL CHECK (op IN ('prefix', 'contains', 'equals')), - pattern TEXT NOT NULL, - folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE -); - -INSERT OR IGNORE INTO folders (id, parent_id, name, collapsed) -VALUES (1, NULL, 'Root', 0); -""" - -_SCHEMA_VERSION = 1 - - -def db_path(config_dir: Path) -> Path: - return config_dir / "organizer.db" - - -class OrganizerDb: - """Owns an aiosqlite connection guarded by an asyncio lock.""" - - __slots__ = ("_connection", "_lock", "_path") - - def __init__(self, path: Path) -> None: - self._path = path - self._connection: aiosqlite.Connection | None = None - self._lock = asyncio.Lock() - - async def _ensure_schema(self, conn: aiosqlite.Connection) -> None: - await ensure_schema( - conn, - schema_sql=_SCHEMA, - schema_version=_SCHEMA_VERSION, - steps={}, - backup_path=self._path, - label="organizer DB", - ) - - async def _get_conn(self) -> aiosqlite.Connection: - if self._connection is None: - if not self._path.parent.exists(): - self._path.parent.mkdir(parents=True, exist_ok=True) - conn = await aiosqlite.connect(str(self._path), check_same_thread=False) - await conn.execute("PRAGMA journal_mode=WAL") - await conn.execute("PRAGMA busy_timeout=5000") - await conn.execute("PRAGMA foreign_keys=ON") - await self._ensure_schema(conn) - self._connection = conn - return self._connection - - async def close(self) -> None: - async with self._lock: - if self._connection is not None: - await self._connection.close() - self._connection = None - - def close_sync(self) -> None: - if self._connection is not None: - self._connection._conn.close() - self._connection = None - - async def load(self) -> OrganizerState: - async with self._lock: - conn = await self._get_conn() - - folders: dict[int, Folder] = {} - async with conn.execute( - "SELECT id, parent_id, name, collapsed FROM folders" - ) as cur: - async for row in cur: - folders[row[0]] = Folder( - id=row[0], - parent_id=row[1], - name=row[2], - collapsed=bool(row[3]), - ) - - placements: dict[str, int] = {} - async with conn.execute( - "SELECT package_id, folder_id FROM placements" - ) as cur: - async for row in cur: - placements[row[0].lower()] = row[1] - - tags: dict[int, Tag] = {} - async with conn.execute("SELECT id, name, color FROM tags") as cur: - async for row in cur: - tags[row[0]] = Tag( - id=row[0], - name=row[1], - color=row[2], - ) - - mod_tags_builder: dict[str, set[int]] = {} - async with conn.execute("SELECT package_id, tag_id FROM mod_tags") as cur: - async for row in cur: - pid = row[0].lower() - mod_tags_builder.setdefault(pid, set()).add(row[1]) - mod_tags = {k: frozenset(v) for k, v in mod_tags_builder.items()} - - rules_list: list[Rule] = [] - async with conn.execute( - """ - SELECT id, position, field, op, pattern, folder_id - FROM rules - ORDER BY position ASC, id ASC - """ - ) as cur: - async for row in cur: - rules_list.append( - Rule( - id=row[0], - position=row[1], - field=row[2], - op=row[3], - pattern=row[4], - folder_id=row[5], - ) - ) - - return OrganizerState( - folders=folders, - placements=placements, - tags=tags, - mod_tags=mod_tags, - rules=tuple(rules_list), - ) - - async def create_folder(self, parent_id: int, name: str) -> Folder: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "INSERT INTO folders (parent_id, name) VALUES (?, ?)", - (parent_id, name), - ) - folder_id = cur.lastrowid - assert folder_id is not None - await conn.commit() - return Folder( - id=folder_id, - parent_id=parent_id, - name=name, - collapsed=False, - ) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def create_folder_with( - self, parent_id: int, name: str, package_ids: Iterable[str] - ) -> Folder: - pids = list(dict.fromkeys(p.strip().lower() for p in package_ids if p.strip())) - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "INSERT INTO folders (parent_id, name) VALUES (?, ?)", - (parent_id, name), - ) - folder_id = cur.lastrowid - assert folder_id is not None - if pids: - await conn.executemany( - """ - INSERT INTO placements (package_id, folder_id) - VALUES (?, ?) - ON CONFLICT (package_id) - DO UPDATE SET folder_id = excluded.folder_id - """, - [(pid, folder_id) for pid in pids], - ) - await conn.commit() - return Folder( - id=folder_id, - parent_id=parent_id, - name=name, - collapsed=False, - ) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def rename_folder(self, folder_id: int, name: str) -> None: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be renamed.") - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE folders SET name = ? WHERE id = ?", (name, folder_id) - ) - if cur.rowcount == 0: - raise OrganizerError(f"Folder with id {folder_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_parent(self, folder_id: int, parent_id: int) -> None: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be moved.") - if folder_id == parent_id: - raise OrganizerError("A folder cannot be its own parent.") - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE folders SET parent_id = ? WHERE id = ?", - (parent_id, folder_id), - ) - if cur.rowcount == 0: - raise OrganizerError(f"Folder with id {folder_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_collapsed(self, folder_id: int, collapsed: bool) -> None: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE folders SET collapsed = ? WHERE id = ?", - (1 if collapsed else 0, folder_id), - ) - if cur.rowcount == 0: - raise OrganizerError(f"Folder with id {folder_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_all_collapsed(self, collapsed: bool) -> None: - async with self._lock: - conn = await self._get_conn() - try: - await conn.execute( - "UPDATE folders SET collapsed = ?", (1 if collapsed else 0,) - ) - await conn.commit() - except Exception: - await conn.rollback() - raise - - async def delete_folder(self, folder_id: int) -> None: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be deleted.") - async with self._lock: - conn = await self._get_conn() - try: - async with conn.execute( - "SELECT 1 FROM folders WHERE id = ?", (folder_id,) - ) as cur: - if await cur.fetchone() is None: - raise OrganizerError( - f"Folder with id {folder_id} does not exist." - ) - - # Collect subtree with recursive CTE and move placements to root - await conn.execute( - """ - WITH RECURSIVE subtree(id) AS ( - SELECT ? - UNION ALL - SELECT f.id FROM folders f JOIN subtree s ON f.parent_id = s.id - ) - UPDATE placements SET folder_id = ? - WHERE folder_id IN (SELECT id FROM subtree) - """, - (folder_id, ROOT_ID), - ) - await conn.execute("DELETE FROM folders WHERE id = ?", (folder_id,)) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def place(self, package_ids: Iterable[str], folder_id: int) -> None: - pids = [p.strip().lower() for p in package_ids if p.strip()] - if not pids: - return - async with self._lock: - conn = await self._get_conn() - try: - await conn.executemany( - """ - INSERT INTO placements (package_id, folder_id) - VALUES (?, ?) - ON CONFLICT (package_id) - DO UPDATE SET folder_id = excluded.folder_id - """, - [(pid, folder_id) for pid in pids], - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def unplace(self, package_ids: Iterable[str]) -> None: - pids = [p.strip().lower() for p in package_ids if p.strip()] - if not pids: - return - async with self._lock: - conn = await self._get_conn() - try: - await conn.executemany( - "DELETE FROM placements WHERE package_id = ?", - [(pid,) for pid in pids], - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def create_tag(self, name: str, color: str) -> Tag: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "INSERT INTO tags (name, color) VALUES (?, ?)", (name, color) - ) - tag_id = cur.lastrowid - assert tag_id is not None - await conn.commit() - return Tag(id=tag_id, name=name, color=color) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def update_tag(self, tag_id: int, name: str, color: str) -> None: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute( - "UPDATE tags SET name = ?, color = ? WHERE id = ?", - (name, color, tag_id), - ) - if cur.rowcount == 0: - raise OrganizerError(f"Tag with id {tag_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def delete_tag(self, tag_id: int) -> None: - async with self._lock: - conn = await self._get_conn() - try: - cur = await conn.execute("DELETE FROM tags WHERE id = ?", (tag_id,)) - if cur.rowcount == 0: - raise OrganizerError(f"Tag with id {tag_id} does not exist.") - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def set_mod_tags( - self, - package_ids: Iterable[str], - add: Iterable[int] = (), - remove: Iterable[int] = (), - ) -> None: - pids = [p.strip().lower() for p in package_ids if p.strip()] - add_ids = list(add) - remove_ids = list(remove) - if not pids or (not add_ids and not remove_ids): - return - - async with self._lock: - conn = await self._get_conn() - try: - if remove_ids: - remove_params = [(pid, tid) for pid in pids for tid in remove_ids] - await conn.executemany( - "DELETE FROM mod_tags WHERE package_id = ? AND tag_id = ?", - remove_params, - ) - if add_ids: - add_params = [(pid, tid) for pid in pids for tid in add_ids] - await conn.executemany( - "INSERT OR IGNORE INTO mod_tags (package_id, tag_id) " - "VALUES (?, ?)", - add_params, - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def replace_rules(self, specs: Sequence[RuleSpec]) -> tuple[Rule, ...]: - async with self._lock: - conn = await self._get_conn() - try: - await conn.execute("DELETE FROM rules") - rules: list[Rule] = [] - for pos, spec in enumerate(specs): - cur = await conn.execute( - """ - INSERT INTO rules (position, field, op, pattern, folder_id) - VALUES (?, ?, ?, ?, ?) - """, - (pos, spec.field, spec.op, spec.pattern, spec.folder_id), - ) - rule_id = cur.lastrowid - assert rule_id is not None - rules.append( - Rule( - id=rule_id, - position=pos, - field=spec.field, - op=spec.op, - pattern=spec.pattern, - folder_id=spec.folder_id, - ) - ) - await conn.commit() - return tuple(rules) - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise - - async def append_named_rules( - self, - parent_id: int, - new_folders: Sequence[str], - rules: Sequence[StandardRule], - ) -> None: - """Create *new_folders* under *parent_id*, then append *rules* after the - existing ones, resolving each rule's folder by name among its children. - """ - async with self._lock: - conn = await self._get_conn() - try: - await conn.executemany( - "INSERT INTO folders (parent_id, name) VALUES (?, ?)", - [(parent_id, name) for name in new_folders], - ) - folder_ids: dict[str, int] = {} - async with conn.execute( - "SELECT id, name FROM folders WHERE parent_id = ?", (parent_id,) - ) as cur: - async for row in cur: - folder_ids[row[1].casefold()] = row[0] - async with conn.execute( - "SELECT COALESCE(MAX(position), -1) + 1 FROM rules" - ) as cur: - row = await cur.fetchone() - assert row is not None - start = int(row[0]) - params: list[tuple[int, str, str, str, int]] = [] - for offset, rule in enumerate(rules): - folder_id = folder_ids.get(rule.folder.casefold()) - if folder_id is None: - raise OrganizerError(f'Folder "{rule.folder}" does not exist.') - params.append( - (start + offset, rule.field, rule.op, rule.pattern, folder_id) - ) - await conn.executemany( - """ - INSERT INTO rules (position, field, op, pattern, folder_id) - VALUES (?, ?, ?, ?, ?) - """, - params, - ) - await conn.commit() - except (sqlite3.IntegrityError, aiosqlite.IntegrityError) as err: - await conn.rollback() - raise OrganizerError(str(err)) from err - except Exception: - await conn.rollback() - raise diff --git a/src/pxmodrim/core/organizer/defaults.py b/src/pxmodrim/core/organizer/defaults.py deleted file mode 100644 index 4fc888f..0000000 --- a/src/pxmodrim/core/organizer/defaults.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.organizer.models import RuleField, RuleOp - - -class StandardRule(msgspec.Struct, frozen=True): - """A built-in auto-folder rule targeting a top-level folder by name.""" - - folder: str - field: RuleField - op: RuleOp - pattern: str - - -def _exact(folder: str, *package_ids: str) -> tuple[StandardRule, ...]: - return tuple(StandardRule(folder, "package_id", "equals", p) for p in package_ids) - - -def _prefixes(folder: str, *prefixes: str) -> tuple[StandardRule, ...]: - return tuple(StandardRule(folder, "package_id", "prefix", p) for p in prefixes) - - -# Package ids are taken from each mod's own About.xml. Multi-category authors -# (brrainz, unlimitedhugs, bs, mlie) are matched by exact id only, and name -# heuristics such as "framework" or "performance" are avoided: they catch -# translations and unrelated content mods. Order matters (first match wins): -# libraries and performance precede the family prefixes that would swallow -# VE Framework and Dubs Performance Analyzer. -STANDARD_RULES: tuple[StandardRule, ...] = ( - *_prefixes("Official", "ludeon.rimworld"), - *_exact( - "Frameworks & Libraries", - "brrainz.harmony", - "unlimitedhugs.hugslib", - "erdelf.humanoidalienraces", - "oskarpotocki.vanillafactionsexpanded.core", - "imranfish.xmlextensions", - "zetrith.prepatcher", - "jecrell.jecstools", - "bs.fishery", - "smashphil.vehicleframework", - ), - *_exact( - "Performance", - "bs.performance", - "krkr.rocketman", - "taranchuk.performanceoptimizer", - ), - *_prefixes("Performance", "dubwise.dubsperformanceanalyzer"), - *_exact( - "Quality of Life", - "brrainz.achtung", - "brrainz.cameraplus", - "unlimitedhugs.allowtool", - "jaxe.rimhud", - "mehni.pickupandhaul", - "avilmask.commonsense", - "dhultgren.smarterconstruction", - "roolo.searchanddestroy", - ), - *_prefixes( - "Vanilla Expanded", - "vanillaexpanded.", - "oskarpotocki.", - "vanillaracesexpanded.", - "vanillaquestsexpanded.", - "vanillastorytellersexpanded.", - ), - StandardRule("Vanilla Expanded", "author", "contains", "oskar potocki"), - *_prefixes("Alpha Mods", "sarg."), - *_prefixes("Combat Extended", "ceteam."), - StandardRule("Combat Extended", "name", "contains", "combat extended"), - *_prefixes("Dubs Mods", "dubwise."), -) diff --git a/src/pxmodrim/core/organizer/models.py b/src/pxmodrim/core/organizer/models.py deleted file mode 100644 index db3703d..0000000 --- a/src/pxmodrim/core/organizer/models.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -import msgspec - -ROOT_ID = 1 -MAX_DEPTH = 3 - -RuleField = Literal["package_id", "name", "author"] -RuleOp = Literal["prefix", "contains", "equals"] - - -class OrganizerError(ValueError): - """Raised when an organizer operation violates domain constraints.""" - - -class Folder(msgspec.Struct, frozen=True): - id: int - parent_id: int | None - name: str - collapsed: bool = False - - -class Tag(msgspec.Struct, frozen=True): - id: int - name: str - color: str - - -class RuleSpec(msgspec.Struct, frozen=True): - field: RuleField - op: RuleOp - pattern: str - folder_id: int - - -class Rule(msgspec.Struct, frozen=True): - id: int - position: int - field: RuleField - op: RuleOp - pattern: str - folder_id: int - - -class OrganizerState(msgspec.Struct, frozen=True): - folders: dict[int, Folder] - placements: dict[str, int] - tags: dict[int, Tag] - mod_tags: dict[str, frozenset[int]] - rules: tuple[Rule, ...] diff --git a/src/pxmodrim/core/organizer/resolve.py b/src/pxmodrim/core/organizer/resolve.py deleted file mode 100644 index 7aa101e..0000000 --- a/src/pxmodrim/core/organizer/resolve.py +++ /dev/null @@ -1,357 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Collection, Iterator, Mapping, Sequence -from typing import Literal - -import msgspec - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.core.models.view.sidebar import PROVIDER_LABELS -from pxmodrim.core.organizer.models import ( - ROOT_ID, - Folder, - OrganizerState, - Rule, - RuleSpec, -) - -Placement = Literal["manual", "rule", "none"] -CheckState = Literal["on", "off", "partial"] -StatusFilter = Literal["all", "active", "inactive"] - - -class TreeQuery(msgspec.Struct, frozen=True): - text: str = "" - tag_ids: frozenset[int] = frozenset() - status: StatusFilter = "all" - provider_ids: frozenset[str] = frozenset() - - @property - def is_empty(self) -> bool: - return ( - not self.text.strip() - and not self.tag_ids - and self.status == "all" - and not self.provider_ids - ) - - -class TreeFilter(msgspec.Struct, frozen=True): - """A sidebar preset (status, provider, or tag) and the mods it selects.""" - - key: str - label: str - count: int - status: StatusFilter = "all" - provider_id: str | None = None - tag_id: int | None = None - - def query(self, text: str = "") -> TreeQuery: - providers = ( - frozenset() if self.provider_id is None else frozenset((self.provider_id,)) - ) - tag_ids = frozenset() if self.tag_id is None else frozenset((self.tag_id,)) - return TreeQuery( - text=text, - tag_ids=tag_ids, - status=self.status, - provider_ids=providers, - ) - - -class ModLeaf(msgspec.Struct, frozen=True): - uuid: str - package_id: str | None - name: str - author: str - enabled: bool - tag_ids: frozenset[int] - placement: Placement - - -class FolderNode(msgspec.Struct, frozen=True): - folder: Folder - depth: int - children: tuple[FolderNode, ...] - mods: tuple[ModLeaf, ...] - total: int - enabled: int - check: CheckState - visible_total: int - own_total: int - own_enabled: int - has_rules: bool - - @property - def own_check(self) -> CheckState: - """Check state of the folder's direct mods only (the root's Ungrouped).""" - return _check_state(self.own_total, self.own_enabled) - - def walk(self) -> Iterator[FolderNode]: - yield self - for child in self.children: - yield from child.walk() - - def all_mods(self) -> Iterator[ModLeaf]: - for node in self.walk(): - yield from node.mods - - -def mod_package_id(mod: ListedMod) -> str | None: - if isinstance(mod, AboutXmlMod): - return str(mod.package_id).lower() - return None - - -def mod_author(mod: ListedMod) -> str: - if isinstance(mod, AboutXmlMod): - return ", ".join(mod.authors) - return "" - - -def _rule_matcher(rule: Rule | RuleSpec) -> Callable[[str], bool]: - pattern = rule.pattern.casefold() - if rule.op == "prefix": - return lambda value: value.startswith(pattern) - if rule.op == "contains": - return lambda value: pattern in value - return lambda value: value == pattern - - -class _Resolver: - __slots__ = ("_placements", "_rules") - - def __init__(self, state: OrganizerState) -> None: - self._placements = state.placements - self._rules = [ - (rule.field, _rule_matcher(rule), rule.folder_id) - for rule in state.rules - if rule.folder_id in state.folders and rule.folder_id != ROOT_ID - ] - - def resolve( - self, package_id: str | None, name: str, author: str - ) -> tuple[int, Placement]: - if package_id is None: - return ROOT_ID, "none" - pid = package_id.lower() - manual = self._placements.get(pid) - if manual is not None: - return manual, "manual" - values = { - "package_id": pid.casefold(), - "name": name.casefold(), - "author": author.casefold(), - } - for field, matches, folder_id in self._rules: - if matches(values[field]): - return folder_id, "rule" - return ROOT_ID, "none" - - -def folder_for( - state: OrganizerState, package_id: str | None, name: str, author: str -) -> tuple[int, Placement]: - return _Resolver(state).resolve(package_id, name, author) - - -def preview_rule_matches( - state: OrganizerState, mods: Mapping[str, ListedMod], specs: Sequence[RuleSpec] -) -> tuple[list[int], int]: - """First-match counts for a draft and currently ungrouped mods it would assign.""" - current = _Resolver(state) - candidates = [ - (spec.field, _rule_matcher(spec)) - if spec.pattern.strip() - and spec.folder_id in state.folders - and spec.folder_id != ROOT_ID - else None - for spec in specs - ] - counts = [0] * len(specs) - assignable = 0 - for mod in mods.values(): - pid = mod_package_id(mod) - if pid is None: - continue - author = mod_author(mod) - values = { - "package_id": pid.casefold(), - "name": mod.name.casefold(), - "author": author.casefold(), - } - for index, candidate in enumerate(candidates): - if candidate is None: - continue - field, matches = candidate - if matches(values[field]): - counts[index] += 1 - if ( - pid.lower() not in state.placements - and current.resolve(pid, mod.name, author)[0] == ROOT_ID - ): - assignable += 1 - break - return counts, assignable - - -def _matches_query(leaf: ModLeaf, provider_id: str, query: TreeQuery) -> bool: - if query.status == "active" and not leaf.enabled: - return False - if query.status == "inactive" and leaf.enabled: - return False - if query.provider_ids and provider_id not in query.provider_ids: - return False - if query.tag_ids and not query.tag_ids <= leaf.tag_ids: - return False - text = query.text.strip().casefold() - if not text: - return True - return ( - text in leaf.name.strip().casefold() - or (leaf.package_id is not None and text in leaf.package_id) - or text in leaf.author.casefold() - ) - - -def _check_state(total: int, enabled: int) -> CheckState: - if total and enabled == total: - return "on" - if enabled == 0: - return "off" - return "partial" - - -def _mod_sort_key(leaf: ModLeaf) -> tuple[str, str]: - return leaf.name.casefold(), leaf.uuid - - -def _folder_sort_key(folder: Folder) -> tuple[str, int]: - return folder.name.casefold(), folder.id - - -def tree_filters( - mods: Mapping[str, ListedMod], - active: Collection[str], - state: OrganizerState | None = None, -) -> list[TreeFilter]: - active_set = active if isinstance(active, (set, frozenset)) else set(active) - enabled = sum(1 for uuid in mods if uuid in active_set) - per_provider: dict[str, int] = {} - for mod in mods.values(): - per_provider[mod.provider_id] = per_provider.get(mod.provider_id, 0) + 1 - filters = [ - TreeFilter("all", "All", len(mods)), - TreeFilter("active", "Active", enabled, status="active"), - TreeFilter("inactive", "Inactive", len(mods) - enabled, status="inactive"), - ] - labelled = sorted( - (PROVIDER_LABELS.get(pid, pid.replace("_", " ").title()), pid) - for pid in per_provider - ) - filters.extend( - TreeFilter(f"provider:{pid}", label, per_provider[pid], provider_id=pid) - for label, pid in labelled - ) - if state is not None and state.tags: - tag_counts: dict[int, int] = dict.fromkeys(state.tags, 0) - for mod in mods.values(): - pid = mod_package_id(mod) - if pid and pid in state.mod_tags: - for tid in state.mod_tags[pid]: - if tid in tag_counts: - tag_counts[tid] += 1 - sorted_tags = sorted( - state.tags.values(), key=lambda t: (t.name.casefold(), t.id) - ) - filters.extend( - TreeFilter( - key=f"tag:{tag.id}", - label=tag.name, - count=tag_counts[tag.id], - tag_id=tag.id, - ) - for tag in sorted_tags - ) - return filters - - -def build_tree( - state: OrganizerState, - mods: Mapping[str, ListedMod], - active: Collection[str], - query: TreeQuery | None = None, -) -> FolderNode: - filtering = query is not None and not query.is_empty - active_set = active if isinstance(active, (set, frozenset)) else set(active) - resolver = _Resolver(state) - - all_by_folder: dict[int, list[ModLeaf]] = {} - visible_by_folder: dict[int, list[ModLeaf]] = {} - for uuid, mod in mods.items(): - pid = mod_package_id(mod) - author = mod_author(mod) - folder_id, placement = resolver.resolve(pid, mod.name, author) - if folder_id not in state.folders: - folder_id, placement = ROOT_ID, "none" - leaf = ModLeaf( - uuid=uuid, - package_id=pid, - name=mod.name, - author=author, - enabled=uuid in active_set, - tag_ids=state.mod_tags.get(pid, frozenset()) if pid else frozenset(), - placement=placement, - ) - all_by_folder.setdefault(folder_id, []).append(leaf) - if ( - query is None - or not filtering - or _matches_query(leaf, mod.provider_id, query) - ): - visible_by_folder.setdefault(folder_id, []).append(leaf) - - children_of: dict[int, list[Folder]] = {} - for folder in state.folders.values(): - if folder.parent_id is not None: - children_of.setdefault(folder.parent_id, []).append(folder) - ruled = {rule.folder_id for rule in state.rules} - - visited: set[int] = set() - - def build(folder: Folder, depth: int) -> FolderNode: - visited.add(folder.id) - children: list[FolderNode] = [] - total = 0 - enabled = 0 - visible_total = 0 - for child in sorted(children_of.get(folder.id, ()), key=_folder_sort_key): - if child.id in visited: - continue - node = build(child, depth + 1) - total += node.total - enabled += node.enabled - visible_total += node.visible_total - if not filtering or node.visible_total: - children.append(node) - own = all_by_folder.get(folder.id, ()) - visible = sorted(visible_by_folder.get(folder.id, ()), key=_mod_sort_key) - own_enabled = sum(1 for leaf in own if leaf.enabled) - total += len(own) - enabled += own_enabled - visible_total += len(visible) - return FolderNode( - folder=folder, - depth=depth, - children=tuple(children), - mods=tuple(visible), - total=total, - enabled=enabled, - check=_check_state(total, enabled), - visible_total=visible_total, - own_total=len(own), - own_enabled=own_enabled, - has_rules=folder.id in ruled, - ) - - return build(state.folders[ROOT_ID], 0) diff --git a/src/pxmodrim/core/organizer/service.py b/src/pxmodrim/core/organizer/service.py deleted file mode 100644 index cdffd93..0000000 --- a/src/pxmodrim/core/organizer/service.py +++ /dev/null @@ -1,369 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, get_args - -import msgspec - -from pxmodrim.core.events import Event -from pxmodrim.core.organizer.defaults import STANDARD_RULES, StandardRule -from pxmodrim.core.organizer.models import ( - MAX_DEPTH, - ROOT_ID, - Folder, - OrganizerError, - OrganizerState, - RuleField, - RuleOp, - RuleSpec, - Tag, -) -from pxmodrim.core.organizer.resolve import ( - FolderNode, - TreeFilter, - TreeQuery, - build_tree, - tree_filters, -) -from pxmodrim.core.plugin import Plugin - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.organizer.db import OrganizerDb - -_COLOR_RE = re.compile(r"#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{8})") -_RULE_FIELDS = frozenset(get_args(RuleField)) -_RULE_OPS = frozenset(get_args(RuleOp)) - - -def _clean_name(name: str, what: str) -> str: - cleaned = name.strip() - if not cleaned: - raise OrganizerError(f"{what} name cannot be empty.") - return cleaned - - -def _clean_package_ids(package_ids: Iterable[str]) -> list[str]: - return list(dict.fromkeys(p.strip().lower() for p in package_ids if p.strip())) - - -class OrganizerService(Plugin): - """Virtual folder tree, tags and auto-folder rules; never touches load order.""" - - name = "organizer" - - changed: Event[None] - - __slots__ = ("_ctx", "_db", "_state", "changed") - - def __init__(self, ctx: CoreContext, db: OrganizerDb) -> None: - self._ctx = ctx - self._db = db - self._state: OrganizerState | None = None - self.changed = Event() - - async def init(self, ctx: CoreContext) -> None: - self._state = await self._db.load() - self.changed.emit(None) - - async def shutdown(self) -> None: - await self._db.close() - - @property - def ready(self) -> bool: - """Whether ``init()`` has loaded state; views built earlier must wait.""" - return self._state is not None - - @property - def state(self) -> OrganizerState: - if self._state is None: - raise RuntimeError("OrganizerService accessed before init()") - return self._state - - async def _commit(self) -> None: - self._state = await self._db.load() - self.changed.emit(None) - - # ── Validation helpers ──────────────────────────────────────────────────── - - def _folder(self, folder_id: int) -> Folder: - folder = self.state.folders.get(folder_id) - if folder is None: - raise OrganizerError("That folder no longer exists.") - return folder - - def _non_root(self, folder_id: int) -> Folder: - if folder_id == ROOT_ID: - raise OrganizerError("The root folder cannot be changed.") - return self._folder(folder_id) - - def _tag(self, tag_id: int) -> Tag: - tag = self.state.tags.get(tag_id) - if tag is None: - raise OrganizerError("That tag no longer exists.") - return tag - - def _depth(self, folder_id: int) -> int: - depth = 0 - current = self._folder(folder_id) - while current.parent_id is not None: - depth += 1 - current = self._folder(current.parent_id) - return depth - - def _children(self, folder_id: int) -> list[Folder]: - return [f for f in self.state.folders.values() if f.parent_id == folder_id] - - def _subtree_ids(self, folder_id: int) -> set[int]: - ids = {folder_id} - pending = [folder_id] - while pending: - for child in self._children(pending.pop()): - if child.id not in ids: - ids.add(child.id) - pending.append(child.id) - return ids - - def _height(self, folder_id: int) -> int: - children = self._children(folder_id) - return 1 + max((self._height(c.id) for c in children), default=0) - - def _check_depth(self, parent_id: int, height: int) -> None: - if self._depth(parent_id) + height > MAX_DEPTH: - raise OrganizerError( - f"Folders can be nested at most {MAX_DEPTH} levels deep." - ) - - def _check_unique_folder_name( - self, parent_id: int, name: str, exclude: int | None = None - ) -> None: - key = name.casefold() - for sibling in self._children(parent_id): - if sibling.id != exclude and sibling.name.casefold() == key: - raise OrganizerError(f'A folder named "{name}" already exists here.') - - def _check_tag(self, name: str, color: str, exclude: int | None = None) -> str: - cleaned = _clean_name(name, "Tag") - key = cleaned.casefold() - for tag in self.state.tags.values(): - if tag.id != exclude and tag.name.casefold() == key: - raise OrganizerError(f'A tag named "{cleaned}" already exists.') - if _COLOR_RE.fullmatch(color) is None: - raise OrganizerError(f'"{color}" is not a valid color (use #RRGGBB).') - return cleaned - - # ── Folders ─────────────────────────────────────────────────────────────── - - async def create_folder(self, name: str, parent_id: int = ROOT_ID) -> Folder: - folder = await self._create_folder(name, parent_id) - await self._commit() - return folder - - async def _create_folder(self, name: str, parent_id: int) -> Folder: - cleaned = _clean_name(name, "Folder") - self._folder(parent_id) - self._check_depth(parent_id, 1) - self._check_unique_folder_name(parent_id, cleaned) - return await self._db.create_folder(parent_id, cleaned) - - async def create_folder_from( - self, name: str, package_ids: Iterable[str], parent_id: int = ROOT_ID - ) -> Folder: - cleaned = _clean_name(name, "Folder") - self._folder(parent_id) - self._check_depth(parent_id, 1) - self._check_unique_folder_name(parent_id, cleaned) - pids = _clean_package_ids(package_ids) - folder = await self._db.create_folder_with(parent_id, cleaned, pids) - await self._commit() - return folder - - async def rename_folder(self, folder_id: int, name: str) -> None: - folder = self._non_root(folder_id) - cleaned = _clean_name(name, "Folder") - if cleaned == folder.name: - return - parent_id = folder.parent_id if folder.parent_id is not None else ROOT_ID - self._check_unique_folder_name(parent_id, cleaned, exclude=folder_id) - await self._db.rename_folder(folder_id, cleaned) - await self._commit() - - async def move_folder(self, folder_id: int, parent_id: int) -> None: - folder = self._non_root(folder_id) - self._folder(parent_id) - if parent_id == folder.parent_id: - return - if parent_id in self._subtree_ids(folder_id): - raise OrganizerError("A folder cannot be moved into itself.") - self._check_depth(parent_id, self._height(folder_id)) - self._check_unique_folder_name(parent_id, folder.name, exclude=folder_id) - await self._db.set_parent(folder_id, parent_id) - await self._commit() - - async def set_collapsed(self, folder_id: int, collapsed: bool) -> None: - if self._folder(folder_id).collapsed == collapsed: - return - await self._db.set_collapsed(folder_id, collapsed) - await self._commit() - - async def set_all_collapsed(self, collapsed: bool) -> None: - if all(folder.collapsed == collapsed for folder in self.state.folders.values()): - return - await self._db.set_all_collapsed(collapsed) - await self._commit() - - async def delete_folder(self, folder_id: int) -> None: - self._non_root(folder_id) - await self._db.delete_folder(folder_id) - await self._commit() - - # ── Placement ───────────────────────────────────────────────────────────── - - async def place(self, package_ids: Iterable[str], folder_id: int) -> None: - self._folder(folder_id) - pids = _clean_package_ids(package_ids) - if not pids: - return - await self._db.place(pids, folder_id) - await self._commit() - - async def ungroup(self, package_ids: Iterable[str]) -> None: - await self.place(package_ids, ROOT_ID) - - async def reset_to_rules(self, package_ids: Iterable[str]) -> None: - pids = _clean_package_ids(package_ids) - if not pids: - return - await self._db.unplace(pids) - await self._commit() - - # ── Tags ────────────────────────────────────────────────────────────────── - - async def create_tag(self, name: str, color: str) -> Tag: - cleaned = self._check_tag(name, color) - tag = await self._db.create_tag(cleaned, color) - await self._commit() - return tag - - async def update_tag(self, tag_id: int, name: str, color: str) -> None: - self._tag(tag_id) - cleaned = self._check_tag(name, color, exclude=tag_id) - await self._db.update_tag(tag_id, cleaned, color) - await self._commit() - - async def delete_tag(self, tag_id: int) -> None: - self._tag(tag_id) - await self._db.delete_tag(tag_id) - await self._commit() - - async def set_mod_tags( - self, - package_ids: Iterable[str], - add: Iterable[int] = (), - remove: Iterable[int] = (), - ) -> None: - pids = _clean_package_ids(package_ids) - add_ids = frozenset(add) - remove_ids = frozenset(remove) - if add_ids & remove_ids: - raise OrganizerError("A tag cannot be added and removed at once.") - for tag_id in add_ids | remove_ids: - self._tag(tag_id) - if not pids or not (add_ids or remove_ids): - return - await self._db.set_mod_tags(pids, add_ids, remove_ids) - await self._commit() - - # ── Rules ───────────────────────────────────────────────────────────────── - - async def set_rules(self, specs: Sequence[RuleSpec]) -> None: - cleaned: list[RuleSpec] = [] - for spec in specs: - if spec.field not in _RULE_FIELDS: - raise OrganizerError(f'Unknown rule field "{spec.field}".') - if spec.op not in _RULE_OPS: - raise OrganizerError(f'Unknown rule operator "{spec.op}".') - pattern = spec.pattern.strip() - if not pattern: - raise OrganizerError("Rule pattern cannot be empty.") - if spec.folder_id == ROOT_ID: - raise OrganizerError("Rules must target a folder, not the root.") - self._folder(spec.folder_id) - cleaned.append(msgspec.structs.replace(spec, pattern=pattern)) - await self._db.replace_rules(cleaned) - await self._commit() - - async def add_standard_rules(self) -> int: - """Append missing built-in rules, creating their top-level folders. - - Returns the number of rules added. - """ - present = { - (rule.field, rule.op, rule.pattern.casefold()) for rule in self.state.rules - } - existing = {folder.name.casefold() for folder in self._children(ROOT_ID)} - rules: list[StandardRule] = [] - new_folders: dict[str, str] = {} - for rule in STANDARD_RULES: - key = (rule.field, rule.op, rule.pattern.casefold()) - if key in present: - continue - present.add(key) - rules.append(rule) - folder_key = rule.folder.casefold() - if folder_key not in existing: - new_folders.setdefault(folder_key, rule.folder) - if not rules: - return 0 - await self._db.append_named_rules(ROOT_ID, list(new_folders.values()), rules) - await self._commit() - return len(rules) - - # ── Read helpers ────────────────────────────────────────────────────────── - - def tree(self, query: TreeQuery | None = None) -> FolderNode: - return build_tree(self.state, self._ctx.all_mods, self._ctx.active_uuids, query) - - def folder_mod_uuids(self, folder_id: int, recursive: bool = True) -> list[str]: - """Mods under *folder_id*; ``recursive=False`` keeps only its direct mods.""" - self._folder(folder_id) - for node in self.tree().walk(): - if node.folder.id == folder_id: - leaves = node.all_mods() if recursive else node.mods - return [leaf.uuid for leaf in leaves] - return [] - - def folder_toggle( - self, folder_id: int, recursive: bool = True - ) -> tuple[list[str], list[str]]: - """(enable, disable) for a folder checkbox click. - - A fully enabled folder disables everything; partial or off enables all. - """ - self._folder(folder_id) - for node in self.tree().walk(): - if node.folder.id == folder_id: - leaves = node.all_mods() if recursive else node.mods - uuids = [leaf.uuid for leaf in leaves] - check = node.check if recursive else node.own_check - return ([], uuids) if check == "on" else (uuids, []) - return [], [] - - def folder_move_targets(self, folder_id: int) -> frozenset[int]: - """Legal destinations, excluding the folder's current parent.""" - folder = self._non_root(folder_id) - subtree = self._subtree_ids(folder_id) - height = self._height(folder_id) - key = folder.name.casefold() - return frozenset( - candidate.id - for candidate in self.state.folders.values() - if candidate.id not in subtree - and candidate.id != folder.parent_id - and self._depth(candidate.id) + height <= MAX_DEPTH - and all(c.name.casefold() != key for c in self._children(candidate.id)) - ) - - def tree_filters(self) -> list[TreeFilter]: - return tree_filters(self._ctx.all_mods, self._ctx.active_uuids, self.state) diff --git a/src/pxmodrim/core/plugin.py b/src/pxmodrim/core/plugin.py deleted file mode 100644 index 50c8f16..0000000 --- a/src/pxmodrim/core/plugin.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from typing import Any, ClassVar - -import toposort -from loguru import logger - - -class Plugin: - """ - Base class for all plugins. - - ``setup(ctx)`` is called synchronously during ``setup_all()`` in dependency - order. Use it to register views, connect signals, etc. ``init(ctx)`` is - async and runs immediately after ``setup()`` — use it for I/O or async init. - """ - - name: ClassVar[str] = "" - dependencies: ClassVar[list[str]] = [] - - def setup(self, ctx: Any) -> None: ... - - async def init(self, ctx: Any) -> None: ... - - async def shutdown(self) -> None: ... - - -class PluginRegistry: - def __init__(self) -> None: - """Initialize the plugin registry with an empty plugin map.""" - self._plugins: dict[str, Plugin] = {} - - def register(self, plugin: Plugin, ctx: Any = None) -> None: - logger.debug("plugin registered: {}", plugin.name) - self._plugins[plugin.name] = plugin - - def get(self, name: str) -> Plugin | None: - return self._plugins.get(name) - - @property - def names(self) -> set[str]: - return set(self._plugins) - - def setup_all(self, ctx: Any, extra_deps: set[str] | None = None) -> None: - available = extra_deps or set() - for name, plugin in self._plugins.items(): - for dep in plugin.dependencies: - if dep not in self._plugins and dep not in available: - raise RuntimeError( - f"Plugin '{name}' depends on '{dep}' which is not registered" - ) - logger.info("setting up {} plugins...", len(self._plugins)) - for p in self._toposort(): - p.setup(ctx) - logger.info("all plugins set up") - - async def init_all(self, ctx: Any) -> None: - logger.info("initializing {} plugins...", len(self._plugins)) - for p in self._toposort(): - await p.init(ctx) - logger.info("all plugins initialized") - - def _toposort(self) -> list[Plugin]: - data = { - name: {dep for dep in plugin.dependencies if dep in self._plugins} - for name, plugin in self._plugins.items() - } - levels = list(toposort.toposort(data)) - return [self._plugins[name] for level in levels for name in sorted(level)] - - async def shutdown_all(self) -> None: - logger.info("shutting down {} plugins...", len(self._plugins)) - for p in reversed(self._toposort()): - await p.shutdown() - logger.info("all plugins shut down") diff --git a/src/pxmodrim/core/plugin_config.py b/src/pxmodrim/core/plugin_config.py deleted file mode 100644 index b86f98b..0000000 --- a/src/pxmodrim/core/plugin_config.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping -from typing import TYPE_CHECKING - -import msgspec -from loguru import logger - -from pxmodrim.core.events import Event - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - - -class PluginConfig[T: msgspec.Struct]: - """A plugin's own settings, kept in ``plugins/.json``. - - *legacy* maps keys of the old shared ``config.json`` to fields of *settings_type*; - they seed the file the first time it is created. Without a *service* the - settings live in memory only. - """ - - __slots__ = ("_filename", "_service", "_value", "changed") - - def __init__( - self, - service: ConfigService | None, - name: str, - settings_type: type[T], - legacy: Mapping[str, str] | None = None, - ) -> None: - self.changed: Event[T] = Event() - self._service = service - self._filename = f"plugins/{name}.json" - stored = ( - None - if service is None - else service.load_existing(self._filename, settings_type) - ) - if stored is None: - stored = self._seed(settings_type, legacy or {}) - self._value: T = stored - - def _seed(self, settings_type: type[T], legacy: Mapping[str, str]) -> T: - if self._service is None or not legacy: - return settings_type() - raw = self._service.load_raw("config.json") - fields = {new: raw[old] for old, new in legacy.items() if old in raw} - try: - value = msgspec.convert(fields, settings_type) - except msgspec.ValidationError as exc: - logger.warning("Ignoring old settings for {}: {}", self._filename, exc) - return settings_type() - if fields: - self._store(value) - return value - - @property - def value(self) -> T: - return self._value - - def update(self, value: T) -> None: - if value == self._value: - return - self._value = value - self._store(value) - self.changed.emit(value) - - def _store(self, value: T) -> None: - if self._service is None: - return - try: - self._service.save(self._filename, value) - except OSError as exc: - logger.warning("Cannot save {}: {}", self._filename, exc) diff --git a/src/pxmodrim/core/profiler.py b/src/pxmodrim/core/profiler.py deleted file mode 100644 index a680768..0000000 --- a/src/pxmodrim/core/profiler.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from contextlib import contextmanager - -from loguru import logger -from ttimer import Timer - - -@contextmanager -def profile(label: str) -> Iterator[Timer]: - """Profile a code block and log the hierarchical timing report on exit. - - Usage: - with profile("discover") as t: - with t("provider.core"): - ... - with t("resolve_active"): - ... - """ - timer = Timer() - with timer(label): - yield timer - logger.debug("Profile report:\n{}", timer.render()) - - -def adopt(parent: Timer, child: Timer) -> None: - """Reparent all of *child*'s nodes under *parent*'s current stack position. - - Each root tree in *child* becomes a sibling of the nodes already under - ``parent._current_node``. After adoption *child* is discarded (its nodes - live in *parent* now). - """ - for stack, node in child._nodes.items(): - parent._nodes[stack] = node - for root in child.trees: - root.parent = parent._current_node diff --git a/src/pxmodrim/core/providers/__init__.py b/src/pxmodrim/core/providers/__init__.py deleted file mode 100644 index df0c4ae..0000000 --- a/src/pxmodrim/core/providers/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from typing import TYPE_CHECKING - -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.providers.core import CoreModProvider -from pxmodrim.core.providers.local import ( - DownloadedModProvider, - LocalModProvider, - SteamWorkshopModProvider, -) - -if TYPE_CHECKING: - from pxmodrim.core.config import PathConfig - - -def create_providers( - paths: PathConfig, pool: ThreadPoolExecutor | None = None -) -> list[BaseModProvider]: - """Build the default provider list from the given path configuration.""" - providers: list[BaseModProvider] = [] - if paths.game: - providers.append(CoreModProvider(Path(paths.game))) - if paths.local: - providers.append(LocalModProvider(Path(paths.local), pool=pool)) - providers.append(DownloadedModProvider(Path(paths.local), pool=pool)) - if paths.workshop: - providers.append(SteamWorkshopModProvider(Path(paths.workshop), pool=pool)) - return providers - - -__all__ = [ - "BaseModProvider", - "CoreModProvider", - "DownloadedModProvider", - "LocalModProvider", - "SteamWorkshopModProvider", - "create_providers", -] diff --git a/src/pxmodrim/core/providers/base.py b/src/pxmodrim/core/providers/base.py deleted file mode 100644 index 7148074..0000000 --- a/src/pxmodrim/core/providers/base.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from abc import ABC, abstractmethod -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -from typing import TYPE_CHECKING - -from ttimer import Timer - -from pxmodrim.core.models.metadata.parsing import create_listed_mod_from_path -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - -if TYPE_CHECKING: - from pxmodrim.core.services.metadata_cache import MetadataCache - - -class BaseModProvider(ABC): - """Abstract mod provider. Subclasses implement :meth:`discover`.""" - - provider_id: str - color: str = "#808080" - - def __init__( - self, - path: Path, - pool: ThreadPoolExecutor | None = None, - metadata_cache: MetadataCache | None = None, - ) -> None: - """Store the root filesystem path this provider scans.""" - self._path = path - self._pool = pool - self._metadata_cache = metadata_cache - - @abstractmethod - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - """Discover mods from this provider's path.""" - - async def _load_mods( - self, - dirs: dict[Path, Path], - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - tm = timer or Timer() - cache = metadata_cache if metadata_cache is not None else self._metadata_cache - - def _stat_all() -> list[tuple[Path, Path, float, int]]: - out: list[tuple[Path, Path, float, int]] = [] - for d, a in dirs.items(): - try: - st = os.stat(a) - except OSError: - continue - out.append((d, a, st.st_mtime, st.st_size)) - return out - - candidates = await asyncio.to_thread(_stat_all) - - result: dict[str, ListedMod] = {} - if force_reparse: - missing = candidates - elif cache is not None: - with tm("cache_lookup"): - cached_mods, missing = await cache.get_mods(candidates, target_version) - for mod in cached_mods.values(): - mod.provider_id = self.provider_id - result[mod.uuid] = mod - else: - missing = candidates - - if missing: - with tm("parse_xml"): - newly_parsed = await self._parse_candidates(missing, target_version) - cache_items = [ - (m, a, mt, sz) - for m, a, mt, sz in newly_parsed - if isinstance(m, AboutXmlMod) - ] - if cache is not None and cache_items: - await cache.put_mods(cache_items, target_version) - for mod, _, _, _ in newly_parsed: - mod.provider_id = self.provider_id - result[mod.uuid] = mod - - # Warm the disk-backed cached_property off the UI thread. - await asyncio.to_thread(lambda: [m.published_file_id for m in result.values()]) - return result - - async def _parse_candidates( - self, - missing: list[tuple[Path, Path, float, int]], - target_version: str, - ) -> list[tuple[ListedMod, Path, float, int]]: - def _parse_one( - item: tuple[Path, Path, float, int], - ) -> tuple[ListedMod, Path, float, int]: - d, a, mtime, size = item - _, mod = create_listed_mod_from_path(d, target_version, about_xml_path=a) - return mod, a, mtime, size - - def _parse_all() -> list[tuple[ListedMod, Path, float, int]]: - pool = self._pool - if pool is not None and len(missing) > 1: - futures = [pool.submit(_parse_one, item) for item in missing] - return [f.result() for f in as_completed(futures)] - return [_parse_one(item) for item in missing] - - return await asyncio.to_thread(_parse_all) diff --git a/src/pxmodrim/core/providers/core.py b/src/pxmodrim/core/providers/core.py deleted file mode 100644 index 9fa533d..0000000 --- a/src/pxmodrim/core/providers/core.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import TYPE_CHECKING - -from loguru import logger -from ttimer import Timer - -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.services.mod_discovery import scan_mod_directory - -if TYPE_CHECKING: - from pxmodrim.core.services.metadata_cache import MetadataCache - - -class CoreModProvider(BaseModProvider): - provider_id = "core" - color = "#e67e22" - - def __init__( - self, - game_path: Path, - metadata_cache: MetadataCache | None = None, - ) -> None: - """Point the provider at the game root (``Data/`` is resolved inside).""" - self._game = game_path - super().__init__(game_path, metadata_cache=metadata_cache) - - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - """Scan ``Data/`` directory for core mods (runs off the main thread).""" - tm = timer or Timer() - data_dir = self._game / "Data" - logger.debug("CoreModProvider scanning: {}", data_dir) - if not data_dir.exists(): - return {} - - with tm("scan_dir"): - dirs = await asyncio.to_thread(scan_mod_directory, data_dir) - - discovered = await self._load_mods( - dirs, - target_version, - timer=tm, - metadata_cache=metadata_cache, - force_reparse=force_reparse, - ) - for mod in discovered.values(): - logger.debug("CoreModProvider found: {} (uuid: {})", mod.name, mod.uuid) - logger.info("CoreModProvider discovered {} mods", len(discovered)) - return discovered diff --git a/src/pxmodrim/core/providers/local.py b/src/pxmodrim/core/providers/local.py deleted file mode 100644 index 35ef7ed..0000000 --- a/src/pxmodrim/core/providers/local.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from typing import TYPE_CHECKING - -from loguru import logger -from ttimer import Timer - -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.providers.base import BaseModProvider -from pxmodrim.core.services.mod_discovery import scan_mod_directory - -if TYPE_CHECKING: - from pxmodrim.core.services.metadata_cache import MetadataCache - - -_in_flight_scans: dict[Path, asyncio.Task[dict[Path, tuple[Path, bool]]]] = {} - - -def _scan_with_pfid(path: Path) -> dict[Path, tuple[Path, bool]]: - return { - d: (a, os.path.exists(os.path.join(d, "About", "PublishedFileId.txt"))) - for d, a in scan_mod_directory(path).items() - } - - -async def _shared_scan(path: Path) -> dict[Path, tuple[Path, bool]]: - """Scan *path* once for concurrent callers. - - Local and downloaded providers share a root and run in parallel; joining an - in-flight scan halves the disk work while never reusing a finished result. - """ - task = _in_flight_scans.get(path) - if task is None or task.get_loop() is not asyncio.get_running_loop(): - task = asyncio.ensure_future(asyncio.to_thread(_scan_with_pfid, path)) - _in_flight_scans[path] = task - task.add_done_callback( - lambda t: ( - _in_flight_scans.pop(path, None) - if _in_flight_scans.get(path) is t - else None - ) - ) - return await asyncio.shield(task) - - -class LocalModProvider(BaseModProvider): - provider_id = "local" - color = "#2ecc71" - - def __init__( - self, - local_path: Path, - pool: ThreadPoolExecutor | None = None, - metadata_cache: MetadataCache | None = None, - ) -> None: - super().__init__(local_path, pool=pool, metadata_cache=metadata_cache) - - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - tm = timer or Timer() - if not self._path.exists(): - logger.debug("LocalModProvider path does not exist: {}", self._path) - return {} - logger.debug("LocalModProvider scanning: {}", self._path) - with tm("scan_dir"): - scanned = await _shared_scan(self._path) - filtered_dirs = {d: a for d, (a, has_pfid) in scanned.items() if not has_pfid} - discovered = await self._load_mods( - filtered_dirs, - target_version, - timer=tm, - metadata_cache=metadata_cache, - force_reparse=force_reparse, - ) - logger.info("LocalModProvider discovered {} mods", len(discovered)) - return discovered - - -class DownloadedModProvider(BaseModProvider): - provider_id = "downloaded" - color = "#3498db" - - def __init__( - self, - local_path: Path, - pool: ThreadPoolExecutor | None = None, - metadata_cache: MetadataCache | None = None, - ) -> None: - super().__init__(local_path, pool=pool, metadata_cache=metadata_cache) - - async def discover( - self, - target_version: str, - timer: Timer | None = None, - metadata_cache: MetadataCache | None = None, - force_reparse: bool = False, - ) -> dict[str, ListedMod]: - tm = timer or Timer() - if not self._path.exists(): - logger.debug("DownloadedModProvider path does not exist: {}", self._path) - return {} - logger.debug("DownloadedModProvider scanning: {}", self._path) - with tm("scan_dir"): - scanned = await _shared_scan(self._path) - filtered_dirs = {d: a for d, (a, has_pfid) in scanned.items() if has_pfid} - discovered = await self._load_mods( - filtered_dirs, - target_version, - timer=tm, - metadata_cache=metadata_cache, - force_reparse=force_reparse, - ) - logger.info("DownloadedModProvider discovered {} mods", len(discovered)) - return discovered - - -class SteamWorkshopModProvider(DownloadedModProvider): - provider_id = "steam" diff --git a/src/pxmodrim/core/services/__init__.py b/src/pxmodrim/core/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/core/services/activation_service.py b/src/pxmodrim/core/services/activation_service.py deleted file mode 100644 index 66324b3..0000000 --- a/src/pxmodrim/core/services/activation_service.py +++ /dev/null @@ -1,341 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Iterable, Iterator -from heapq import heapify, heappop, heappush - -from pxmodrim.core.checker.graph import ( - ConstraintEdge, - ConstraintGraph, - EdgeType, - PackageId, -) -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - - -class ActivationService: - """Enable/disable mods without reordering the rest of the active list.""" - - __slots__ = ("_ctx",) - - def __init__(self, ctx: CoreContext) -> None: - self._ctx = ctx - - def dependents_of( - self, uuids: Iterable[str], *, enable: Iterable[str] = () - ) -> list[str]: - """Active dependents of packages lost after the enable/disable changes.""" - excluded = set(uuids) - all_mods = self._ctx.all_mods - remaining = { - PackageId(mod.package_id) - for uuid in (*self._ctx.active_uuids, *enable) - if uuid not in excluded - and isinstance(mod := all_mods.get(uuid), AboutXmlMod) - } - roots: set[PackageId] = set() - for uuid in excluded: - mod = all_mods.get(uuid) - if ( - isinstance(mod, AboutXmlMod) - and PackageId(mod.package_id) not in remaining - ): - roots.add(PackageId(mod.package_id)) - if not roots: - return [] - - graph = self._ctx.diagnostics_service.constraint_graph - dependents: set[PackageId] = set() - pending = list(roots) - while pending: - target = pending.pop() - for edge in graph.incoming_of_type(target, EdgeType.DEPENDENCY): - if edge.source not in roots and edge.source not in dependents: - dependents.add(edge.source) - pending.append(edge.source) - - result: list[str] = [] - for uuid in self._ctx.active_uuids: - if uuid in excluded: - continue - mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod) and PackageId(mod.package_id) in dependents: - result.append(uuid) - return result - - def apply(self, enable: Iterable[str] = (), disable: Iterable[str] = ()) -> bool: - """Apply batch enable/disable changes; return whether active state changed.""" - active = self._ctx.active_uuids - disable_set = set(disable) - new_active = [uuid for uuid in active if uuid not in disable_set] - candidates = self._enable_candidates(enable, disable_set, set(active)) - - placement = _Placement( - new_active, candidates, self._ctx.all_mods, self._build_edges - ) - result = placement.assemble() - if result == active: - return False - - self._ctx.set_active(result) - return True - - def _enable_candidates( - self, enable: Iterable[str], disable_set: set[str], active_set: set[str] - ) -> list[str]: - all_mods = self._ctx.all_mods - candidates: list[str] = [] - seen: set[str] = set() - for uuid in enable: - if uuid in seen or uuid in disable_set: - continue - seen.add(uuid) - if uuid in all_mods and uuid not in active_set: - candidates.append(uuid) - return candidates - - def _build_edges( - self, - mods: dict[PackageId, AboutXmlMod], - order: list[PackageId], - candidates: Iterable[PackageId], - ) -> list[tuple[PackageId, PackageId]]: - """(before, after) pairs between *mods* touching any of *candidates*. - - Edges through ids outside *mods* (uninstalled optional mods) are - dropped so they cannot chain unrelated mods together. - """ - graph = ConstraintGraph() - graph.build( - mods, - order, - self._ctx.config.sort, - self._ctx.diagnostics_service.community_rules, - ) - # Placement ignores active-to-active edges, so only edges incident to a - # candidate matter; the graph still resolves the full alternative map. - incident: set[ConstraintEdge] = set() - for pid in candidates: - incident.update(graph.outgoing(pid)) - incident.update(graph.incoming(pid)) - edges: list[tuple[PackageId, PackageId]] = [] - for edge in incident: - if edge.type == EdgeType.LOAD_BEFORE: - before, after = edge.source, edge.target - elif edge.type in (EdgeType.DEPENDENCY, EdgeType.LOAD_AFTER): - before, after = edge.target, edge.source - else: - continue - if before != after and before in mods and after in mods: - edges.append((before, after)) - return edges - - -class _Placement: - """Slots enable candidates into gaps of a fixed active order. - - Gap ``i`` means "just before ``new_active[i]``"; gap ``len(new_active)`` - is the end. Bounds come only from direct edges to active mods, propagated - through batch candidates, because the active order itself may already - violate constraints and must not be re-derived transitively. - """ - - def __init__( - self, - new_active: list[str], - candidates: list[str], - all_mods: dict[str, ListedMod], - build_edges: Callable[ - [dict[PackageId, AboutXmlMod], list[PackageId], Iterable[PackageId]], - list[tuple[PackageId, PackageId]], - ], - ) -> None: - self._new_active = new_active - self._candidates = candidates - self._end = len(new_active) - - mods: dict[PackageId, AboutXmlMod] = {} - order: list[PackageId] = [] - self._first_pos: dict[PackageId, int] = {} - self._last_pos: dict[PackageId, int] = {} - for index, uuid in enumerate(new_active): - mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): - pid = PackageId(mod.package_id) - self._first_pos.setdefault(pid, index) - self._last_pos[pid] = index - if pid not in mods: - mods[pid] = mod - order.append(pid) - - self._uuids_by_pid: dict[PackageId, list[str]] = {} - for uuid in candidates: - mod = all_mods[uuid] - if not isinstance(mod, AboutXmlMod): - continue - pid = PackageId(mod.package_id) - self._uuids_by_pid.setdefault(pid, []).append(uuid) - if pid not in mods: - mods[pid] = mod - order.append(pid) - - # Batch-internal adjacency plus direct bounds against active mods. - self._after: dict[str, set[str]] = {uuid: set() for uuid in candidates} - self._before: dict[str, set[str]] = {uuid: set() for uuid in candidates} - self._floor: dict[str, int] = {} - self._ceiling: dict[str, int] = {} - if self._uuids_by_pid: - for before, after in build_edges(mods, order, self._uuids_by_pid): - self._add_edge(before, after) - - def _add_edge(self, before: PackageId, after: PackageId) -> None: - before_uuids = self._uuids_by_pid.get(before, ()) - after_uuids = self._uuids_by_pid.get(after, ()) - for b in before_uuids: - for a in after_uuids: - if a != b: - self._after[b].add(a) - self._before[a].add(b) - first = self._first_pos.get(after) - if first is not None: - self._ceiling[b] = min(self._ceiling.get(b, first), first) - last = self._last_pos.get(before) - if last is not None: - for a in after_uuids: - self._floor[a] = max(self._floor.get(a, last + 1), last + 1) - - def assemble(self) -> list[str]: - ordered, cyclic = self._order_batch() - ceilings = self._propagated_ceilings(ordered, cyclic) - buckets: list[list[str]] = [[] for _ in range(self._end + 1)] - gaps: dict[str, int] = {} - for uuid in ordered: - gap = self._gap(uuid, cyclic, ceilings, gaps) - gaps[uuid] = gap - buckets[gap].append(uuid) - - result: list[str] = [] - for index, uuid in enumerate(self._new_active): - result.extend(buckets[index]) - result.append(uuid) - result.extend(buckets[-1]) - return result - - def _gap( - self, - uuid: str, - cyclic: set[str], - ceilings: dict[str, int], - gaps: dict[str, int], - ) -> int: - if uuid in cyclic: - return self._end - floors = [gaps[b] for b in self._before[uuid]] - if uuid in self._floor: - floors.append(self._floor[uuid]) - ceiling = ceilings.get(uuid) - if floors: - earliest = max(floors) - if ceiling is not None and earliest > ceiling: - return self._end - return earliest - return self._end if ceiling is None else ceiling - - def _propagated_ceilings( - self, ordered: list[str], cyclic: set[str] - ) -> dict[str, int]: - """Earliest active position each candidate must precede, via batch chains.""" - ceilings: dict[str, int] = {} - for uuid in reversed(ordered): - if uuid in cyclic: - continue - bounds = [ceilings[a] for a in self._after[uuid] if a in ceilings] - if uuid in self._ceiling: - bounds.append(self._ceiling[uuid]) - if bounds: - ceilings[uuid] = min(bounds) - return ceilings - - def _order_batch(self) -> tuple[list[str], set[str]]: - """Topologically order candidates by input order; cycle members as a unit.""" - components = _strongly_connected(self._candidates, self._after) - component_of = { - uuid: index for index, members in enumerate(components) for uuid in members - } - cyclic = { - uuid for members in components if len(members) > 1 for uuid in members - } - - input_index = {uuid: index for index, uuid in enumerate(self._candidates)} - successors: list[set[int]] = [set() for _ in components] - indegree = [0] * len(components) - for uuid, afters in self._after.items(): - source = component_of[uuid] - for after in afters: - target = component_of[after] - if target != source and target not in successors[source]: - successors[source].add(target) - indegree[target] += 1 - - for members in components: - members.sort(key=input_index.__getitem__) - ready = [ - (input_index[members[0]], index) - for index, members in enumerate(components) - if indegree[index] == 0 - ] - heapify(ready) - ordered: list[str] = [] - while ready: - _, index = heappop(ready) - ordered.extend(components[index]) - for target in successors[index]: - indegree[target] -= 1 - if indegree[target] == 0: - heappush(ready, (input_index[components[target][0]], target)) - return ordered, cyclic - - -def _strongly_connected( - nodes: list[str], adjacent: dict[str, set[str]] -) -> list[list[str]]: - """Iterative Tarjan SCC.""" - index_of: dict[str, int] = {} - lowlink: dict[str, int] = {} - stack: list[str] = [] - on_stack: set[str] = set() - components: list[list[str]] = [] - for root in nodes: - if root in index_of: - continue - work: list[tuple[str, Iterator[str]]] = [] - index_of[root] = lowlink[root] = len(index_of) - stack.append(root) - on_stack.add(root) - work.append((root, iter(adjacent[root]))) - while work: - node, children = work[-1] - for child in children: - if child not in index_of: - index_of[child] = lowlink[child] = len(index_of) - stack.append(child) - on_stack.add(child) - work.append((child, iter(adjacent[child]))) - break - if child in on_stack: - lowlink[node] = min(lowlink[node], index_of[child]) - else: - work.pop() - if work: - parent = work[-1][0] - lowlink[parent] = min(lowlink[parent], lowlink[node]) - if lowlink[node] == index_of[node]: - component: list[str] = [] - while True: - member = stack.pop() - on_stack.discard(member) - component.append(member) - if member == node: - break - components.append(component) - return components diff --git a/src/pxmodrim/core/services/diagnostics_service.py b/src/pxmodrim/core/services/diagnostics_service.py deleted file mode 100644 index eecb813..0000000 --- a/src/pxmodrim/core/services/diagnostics_service.py +++ /dev/null @@ -1,385 +0,0 @@ -from __future__ import annotations - -import asyncio -import operator -from pathlib import Path -from typing import TYPE_CHECKING, Protocol - -from ttimer import Timer - -from pxmodrim.core.checker.checker import ModChecker -from pxmodrim.core.checker.databases import ( - NoVersionWarningService, - UseThisInsteadService, -) -from pxmodrim.core.checker.issues import ( - CycleIssueChecker, - DependencyIssueChecker, - GameVersionIssueChecker, - IncompatibilityIssueChecker, - LoadOrderIssueChecker, - ReplacementIssueChecker, -) -from pxmodrim.core.checker.models import ModDiagnostics -from pxmodrim.core.events import Event -from pxmodrim.core.models.view.diagnostics import ( - ModDiagnosticsView, - ModIssueView, -) -from pxmodrim.core.models.view.sidebar import ( - PROVIDER_LABELS, - ActiveModsEntry, - AllModsEntry, - ErrorModsEntry, - InactiveModsEntry, - ProviderModsEntry, - SidebarEntry, - WarningModsEntry, -) -from pxmodrim.core.sort.models import CommunityRule, PackageId - -if TYPE_CHECKING: - from pxmodrim.core.checker.graph import ConstraintGraph - from pxmodrim.core.config import ConfigService - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod - - -DATABASE_MAX_AGE_S = 24 * 3600 - - -def _needs_refresh(loaded: object, service: _AgedCache) -> bool: - age = service.cache_age_s() - return not loaded or age is None or age > DATABASE_MAX_AGE_S - - -class _AgedCache(Protocol): - def cache_age_s(self) -> float | None: ... - - -class DiagnosticsService: - diagnostics_summary_changed: Event[dict[str, ModDiagnosticsView]] - status_message_changed: Event[str] - background_task_changed: Event[str] - sidebar_entries_changed: Event[list[SidebarEntry]] - - __slots__ = ( - "_checker", - "_community_rules", - "_ctx", - "_db_task", - "_last_active_uuids", - "_last_summary", - "_last_summary_sources", - "_no_version_warning_service", - "_sidebar_base", - "_use_this_instead_service", - "background_task_changed", - "diagnostics_summary_changed", - "sidebar_entries_changed", - "status_message_changed", - ) - - def __init__( - self, ctx: CoreContext, config_service: ConfigService | None = None - ) -> None: - """Initialise diagnostics with checkers, databases, and community rules.""" - self.diagnostics_summary_changed = Event() - self.status_message_changed = Event() - self.background_task_changed = Event() - self._db_task: asyncio.Task[None] | None = None - self.sidebar_entries_changed = Event() - self._ctx = ctx - cs = config_service or ctx.config_service - self._no_version_warning_service = NoVersionWarningService(cs) - self._use_this_instead_service = UseThisInsteadService(cs) - self._community_rules: dict[PackageId, CommunityRule] | None = None - self._last_active_uuids: list[str] = [] - self._last_summary: dict[str, ModDiagnosticsView] = {} - # Diagnostics each summary view was built from; reorders keep most - # diagnostics objects, so their views can be reused. - self._last_summary_sources: dict[str, ModDiagnostics] = {} - self._sidebar_base: _SidebarBase | None = None - self._checker = ModChecker( - checkers=[ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - LoadOrderIssueChecker(), - CycleIssueChecker(), - GameVersionIssueChecker(), - ReplacementIssueChecker(), - ], - settings=ctx.config.sort, - target_version=ctx.target_version, - on_diagnostics_changed=self._on_checker_diagnostics_changed, - ) - - # ── Lifecycle ────────────────────────────────────────────── - - async def initialize(self, timer: Timer | None = None) -> None: - """Load databases and community rules, then apply them to the checker.""" - if timer is None: - await self._ensure_databases() - self._community_rules = await self._load_community_rules() - if self._community_rules: - self._checker.set_community_rules(self._community_rules) - return - with timer("diagnostics.init"): - with timer("ensure_databases"): - await self._ensure_databases() - with timer("load_community_rules"): - self._community_rules = await self._load_community_rules() - if self._community_rules: - self._checker.set_community_rules(self._community_rules) - - async def _ensure_databases(self) -> None: - """Apply cached databases now; refresh missing or stale ones in background.""" - nvw = await asyncio.to_thread(self._no_version_warning_service.load_if_exists) - if nvw: - self._checker.set_no_version_warning(nvw) - uti = await asyncio.to_thread(self._use_this_instead_service.load_if_exists) - if uti: - self._checker.set_use_this_instead(uti) - refresh_nvw = _needs_refresh(nvw, self._no_version_warning_service) - refresh_uti = _needs_refresh(uti, self._use_this_instead_service) - if (refresh_nvw or refresh_uti) and ( - self._db_task is None or self._db_task.done() - ): - self._db_task = asyncio.create_task( - self._download_databases(refresh_nvw, refresh_uti) - ) - - async def _download_databases(self, nvw: bool, uti: bool) -> None: - self.background_task_changed.emit("Downloading rule databases\u2026") - changed = False - try: - if nvw: - pids = await self._no_version_warning_service.ensure(force=True) - if pids: - self._checker.set_no_version_warning(pids) - changed = True - if uti: - entries = await self._use_this_instead_service.ensure(force=True) - if entries: - self._checker.set_use_this_instead(entries) - changed = True - if changed: - self.rebuild() - finally: - self.background_task_changed.emit("") - - async def _load_community_rules(self) -> dict[PackageId, CommunityRule] | None: - """Load community sorting rules from disk, if enabled.""" - if not self._ctx.config.sort.use_community_rules: - return None - from pxmodrim.core.sort.community import load_community_rules - - path = self._ctx.config.paths.community_rules_file - if not path: - path = str(self._ctx.config_service.config_dir / "communityRules.json") - if not path or not Path(path).exists(): - return None - return load_community_rules(Path(path)) - - # ── Mutations ────────────────────────────────────────────── - - def rebuild( - self, active_uuids: list[str] | None = None, timer: Timer | None = None - ) -> None: - """Rebuild the checker for all mods with the given active UUIDs.""" - if active_uuids is None: - active_uuids = self._ctx.active_uuids - self._last_active_uuids = active_uuids - if timer is None: - self._checker.rebuild(self._ctx.all_mods, active_uuids) - return - with timer("diagnostics.rebuild"): - self._checker.rebuild(self._ctx.all_mods, active_uuids, timer=timer) - - def reorder(self, active_uuids: list[str]) -> None: - """Notify the checker of a new active mod order.""" - self._last_active_uuids = active_uuids - self._checker.reorder(active_uuids) - - # ── Queries ──────────────────────────────────────────────── - - @property - def active_mods_by_pid(self) -> dict[PackageId, AboutXmlMod]: - return self._checker.active_mods - - @property - def constraint_graph(self) -> ConstraintGraph: - return self._checker.graph - - @property - def active_ordered_pids(self) -> list[PackageId]: - return self._checker.ordered_pids - - @property - def community_rules(self) -> dict[PackageId, CommunityRule] | None: - return self._checker.community_rules - - def issues_for(self, uuid: str) -> list[ModIssueView]: - """Return view models for all diagnostics associated with a given mod UUID.""" - diag = self._checker.diagnostics_for(uuid) - return self._to_issue_views(diag) if diag else [] - - # ── Conversions ──────────────────────────────────────────── - - @staticmethod - def _to_issue_views(diag: ModDiagnostics) -> list[ModIssueView]: - """Convert a ModDiagnostics object into a list of ModIssueView models.""" - views: list[ModIssueView] = [] - for issue in diag.errors: - views.append( - ModIssueView( - category=issue.category, - category_display_name=issue.category_display_name or issue.category, - detail=issue.detail_message or None, - is_error=True, - ) - ) - for issue in diag.warnings: - views.append( - ModIssueView( - category=issue.category, - category_display_name=issue.category_display_name or issue.category, - detail=issue.detail_message or None, - is_error=False, - ) - ) - return views - - @staticmethod - def _to_view(diag: ModDiagnostics) -> ModDiagnosticsView: - """Convert diagnostics to a serialisable view for the UI.""" - return ModDiagnosticsView( - has_errors=diag.has_errors, - has_warnings=diag.has_warnings, - error_tooltip=diag.error_tooltip, - warning_tooltip=diag.warning_tooltip, - ) - - # ── Checker callback ─────────────────────────────────────── - - def _on_checker_diagnostics_changed( - self, diagnostics: dict[str, ModDiagnostics] - ) -> None: - """Callback from checker; emit updated summary, status, and sidebar signals.""" - previous_views = self._last_summary - previous_sources = self._last_summary_sources - summary: dict[str, ModDiagnosticsView] = {} - for uuid, diag in diagnostics.items(): - if previous_sources.get(uuid) is diag: - summary[uuid] = previous_views[uuid] - else: - summary[uuid] = self._to_view(diag) - self._last_summary = summary - self._last_summary_sources = diagnostics - # Sidebar first: the summary handler re-applies the current sidebar - # filter and must see the entries for this state, not the previous one. - self.sidebar_entries_changed.emit(self._build_sidebar_entries()) - self.diagnostics_summary_changed.emit(self._last_summary) - self.status_message_changed.emit(self._format_status()) - - def summary_for(self, uuid: str) -> ModDiagnosticsView | None: - return self._last_summary.get(uuid) - - # ── Status ────────────────────────────────────────────────── - - def active_error_count(self) -> int: - diagnostics = self._checker.active_mod_diagnostics() - return sum(1 for d in diagnostics.values() if d.has_errors) - - def _format_status(self) -> str: - """Build a human-readable status string showing active/error/warning counts.""" - active = self._last_active_uuids - diagnostics = self._checker.active_mod_diagnostics() - - err_count = sum(1 for d in diagnostics.values() if d.has_errors) - warn_count = sum(1 for d in diagnostics.values() if d.has_warnings) - - parts: list[str] = [f"{len(active)} active"] - if err_count: - parts.append(f"{err_count} with errors") - if warn_count: - parts.append(f"{warn_count} with warnings") - return " | ".join(parts) - - # ── Sidebar ───────────────────────────────────────────────── - - def _build_sidebar_entries(self) -> list[SidebarEntry]: - """Build sidebar entries categorised by provider, active, error, warning.""" - mods = self._ctx.all_mods - active = self._last_active_uuids - diagnostics = self._checker.active_mod_diagnostics() - - base = self._sidebar_base - if base is None or not base.matches(mods): - base = self._sidebar_base = _SidebarBase(mods) - - errors = set(base.invalid) - warnings: set[str] = set() - for u, diag in diagnostics.items(): - if u not in mods: - continue - if diag.has_errors: - errors.add(u) - if diag.has_warnings: - warnings.add(u) - - all_entry = AllModsEntry() - active_entry = ActiveModsEntry() - inactive_entry = InactiveModsEntry() - error_entry = ErrorModsEntry() - warning_entry = WarningModsEntry() - all_entry.visible_uuids = set(base.all_uuids) - active_entry.visible_uuids = set(active) - inactive_entry.visible_uuids = ( - all_entry.visible_uuids - active_entry.visible_uuids - ) - error_entry.visible_uuids = errors - warning_entry.visible_uuids = warnings - entries: list[SidebarEntry] = [ - all_entry, - active_entry, - inactive_entry, - error_entry, - warning_entry, - ] - for entry in entries: - entry.refresh_count() - for pid, uuids in base.by_provider: - entries.append( - ProviderModsEntry(pid, PROVIDER_LABELS.get(pid, pid), set(uuids)) - ) - - return entries - - -class _SidebarBase: - """Diagnostics-independent sidebar partitions, reused until the mods change.""" - - __slots__ = ("all_uuids", "by_provider", "invalid", "mods") - - def __init__(self, mods: dict[str, ListedMod]) -> None: - self.mods = mods - by_provider: dict[str, set[str]] = {} - invalid: set[str] = set() - for u, m in mods.items(): - by_provider.setdefault(m.provider_id, set()).add(u) - if not m.valid: - invalid.add(u) - self.by_provider = sorted(by_provider.items(), key=lambda item: item[0]) - self.invalid = frozenset(invalid) - self.all_uuids = frozenset(mods) - - def matches(self, mods: dict[str, ListedMod]) -> bool: - # Callers pass copies of one dict, so identical iteration order is the - # common case; any difference just forces a (correct) recompute. - cached = self.mods - return ( - len(cached) == len(mods) - and all(map(operator.is_, cached.values(), mods.values())) - and all(map(operator.eq, cached, mods)) - ) diff --git a/src/pxmodrim/core/services/game_launcher.py b/src/pxmodrim/core/services/game_launcher.py deleted file mode 100644 index 03a7eec..0000000 --- a/src/pxmodrim/core/services/game_launcher.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import re -import shlex -import subprocess -import sys -import webbrowser -from pathlib import Path -from urllib.parse import quote - -import psutil -from loguru import logger - -from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, LaunchStrategy -from pxmodrim.core.context import CoreContext - -_PROCESS_NAMES = frozenset( - { - "RimWorld", - "RimWorldLinux", - "RimWorldWin64.exe", - "RimWorldWin.exe", - "RimWorld.exe", - "RimWorld by Ludeon Studios", - } -) -_COMMAND_TOKEN = "%command%" -_ENV_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=") -_POLL_START = 0.5 -_POLL_EXIT = 2.0 - - -def _split(text: str) -> list[str]: - return shlex.split(text, posix=sys.platform != "win32") - - -def build_direct_command( - exe: str, wrapper: str, args: str -) -> tuple[list[str], dict[str, str]]: - """Expand wrapper/args into argv plus extra env. - - Wrapper follows Steam's convention: leading KEY=VALUE tokens are env vars and - ``%command%`` marks where the game goes; without it the wrapper is a prefix. - """ - game = [exe, *_split(args)] - tokens = _split(wrapper) - env: dict[str, str] = {} - while tokens and _ENV_ASSIGNMENT.match(tokens[0]): - key, _, value = tokens.pop(0).partition("=") - env[key] = value - if _COMMAND_TOKEN in tokens: - i = tokens.index(_COMMAND_TOKEN) - return [*tokens[:i], *game, *tokens[i + 1 :]], env - return [*tokens, *game], env - - -class GameLauncher: - __slots__ = ("_baseline", "_ctx", "_proc") - - def __init__(self, ctx: CoreContext) -> None: - self._ctx = ctx - self._proc: subprocess.Popen[bytes] | None = None - self._baseline: frozenset[int] = frozenset() - - async def launch(self, strategy: LaunchStrategy) -> tuple[bool, str]: - if not self._ctx.config.paths.game: - logger.warning("Launch aborted — game path not configured") - return False, "Game path not configured" - - logger.info("Launching game with strategy: {}", strategy.name) - self._baseline = await asyncio.to_thread(self._game_pids) - if strategy == LaunchStrategy.DIRECT: - return await self._launch_direct() - return await self._launch_steam() - - @staticmethod - def _game_pids() -> frozenset[int]: - return frozenset( - proc.pid - for proc in psutil.process_iter(["name", "status"]) - if proc.info["name"] in _PROCESS_NAMES - and proc.info["status"] != psutil.STATUS_ZOMBIE - ) - - @classmethod - def is_running(cls) -> bool: - return bool(cls._game_pids()) - - def _launched_alive(self) -> bool: - """A game process that appeared after launch; stale ones are ignored.""" - return bool(self._game_pids() - self._baseline) - - async def wait_for_start(self, timeout: float = 60.0) -> bool: - """Wait until a game process appears; False if it died or never showed.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - if await asyncio.to_thread(self._launched_alive): - return True - if self._proc is not None and self._proc.poll() is not None: - return False - await asyncio.sleep(_POLL_START) - return False - - async def wait_for_exit(self) -> int | None: - """Block until no game process remains; the exit code if we spawned it.""" - while await asyncio.to_thread(self._launched_alive): - if self._proc is not None: - self._proc.poll() - await asyncio.sleep(_POLL_EXIT) - if self._proc is None: - return None - code = self._proc.poll() - self._proc = None - return code - - async def _launch_direct(self) -> tuple[bool, str]: - game = Path(self._ctx.config.paths.game) - exe = self._find_executable(game) - if exe is None: - logger.warning("Direct launch failed — executable not found in {}", game) - return False, "Game executable not found" - - self._ensure_steam_appid(game) - - cfg = self._ctx.config - try: - if sys.platform == "darwin": - argv = ["open", str(exe), "--args", *_split(cfg.launch_args)] - env: dict[str, str] = {} - else: - argv, env = build_direct_command( - str(exe), cfg.launch_wrapper, cfg.launch_args - ) - except ValueError as e: - logger.warning("Invalid launch arguments: {}", e) - return False, f"Invalid launch arguments: {e}" - - logger.info("Spawning process: {} (cwd: {})", argv, game) - try: - self._proc = await asyncio.to_thread( - subprocess.Popen, - argv, - cwd=str(game), - env={**os.environ, **env}, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - return True, "Game launched" - except (OSError, ValueError) as e: - logger.warning("Direct launch failed: {}", e) - return False, f"Failed to launch: {e}" - - async def _launch_steam(self) -> tuple[bool, str]: - args = self._ctx.config.launch_args.strip() - if args: - url = f"steam://run/{RIMWORLD_STEAM_APP_ID}//{quote(args, safe='')}/" - else: - url = f"steam://rungameid/{RIMWORLD_STEAM_APP_ID}" - logger.info("Opening Steam URL: {}", url) - try: - await asyncio.to_thread(webbrowser.open, url) - return True, "Launching via Steam..." - except webbrowser.Error as e: - logger.warning("Steam launch failed: {}", e) - return False, f"Steam launch failed: {e}" - - @staticmethod - def _find_executable(game_path: Path) -> Path | None: - if sys.platform == "linux": - for name in ("RimWorld", "RimWorldLinux"): - exe = game_path / name - if exe.is_file() and os.access(exe, os.X_OK): - return exe - return None - if sys.platform == "darwin": - exe = game_path / "RimWorld.app" - if exe.is_dir(): - return exe - apps = list(game_path.glob("*.app")) - return apps[0] if apps else None - if sys.platform == "win32": - for name in ("RimWorld.exe", "RimWorldWin64.exe", "RimWorldWin.exe"): - exe = game_path / name - if exe.is_file(): - return exe - return None - return None - - @staticmethod - def _ensure_steam_appid(game_path: Path) -> None: - app_id_path = ( - game_path.parent / "steam_appid.txt" - if sys.platform == "darwin" - else game_path / "steam_appid.txt" - ) - if not app_id_path.exists(): - try: - app_id_path.write_text(RIMWORLD_STEAM_APP_ID) - logger.debug("Created {}", app_id_path) - except OSError as e: - logger.warning("Failed to create steam_appid.txt: {}", e) diff --git a/src/pxmodrim/core/services/metadata_cache.py b/src/pxmodrim/core/services/metadata_cache.py deleted file mode 100644 index 3750cfb..0000000 --- a/src/pxmodrim/core/services/metadata_cache.py +++ /dev/null @@ -1,389 +0,0 @@ -from __future__ import annotations - -import asyncio -import contextlib -import json -import os -import sqlite3 -from pathlib import Path -from typing import Any - -import aiosqlite -from loguru import logger - -from pxmodrim.core.migrator import ensure_schema -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) - -_SCHEMA = """ - CREATE TABLE IF NOT EXISTS mod_metadata ( - mod_path TEXT PRIMARY KEY, - about_path TEXT NOT NULL, - mtime REAL NOT NULL, - file_size INTEGER NOT NULL DEFAULT 0, - target_version TEXT NOT NULL, - payload_json TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_mm_about - ON mod_metadata(about_path); -""" - -_SCHEMA_VERSION = 2 - - -def metadata_cache_path(config_dir: Path) -> Path: - return config_dir / "metadata-cache.db" - - -def normalize_path(path: Path | str) -> str: - # Same result as str(Path(path).resolve()) without the pathlib round-trips. - return os.path.realpath(path) - - -def normalize_paths(paths: list[Path]) -> list[str]: - """``normalize_path`` for many paths, resolving each shared parent only once.""" - parents: dict[str, str] = {} - out: list[str] = [] - for path in paths: - head, tail = os.path.split(os.fspath(path)) - if not head or tail in ("", ".", "..") or os.path.islink(path): - out.append(os.path.realpath(path)) - continue - real_head = parents.get(head) - if real_head is None: - real_head = parents[head] = os.path.realpath(head) - out.append(os.path.join(real_head, tail)) - return out - - -def serialize_mod(mod: AboutXmlMod) -> dict[str, Any]: - return { - "name": mod.name, - "package_id": str(mod.package_id), - "authors": list(mod.authors), - "mod_version": mod.mod_version, - "mod_icon_path": str(mod.mod_icon_path) if mod.mod_icon_path else None, - "steam_app_id": mod.steam_app_id, - "url": mod.url, - "description": mod.description, - "supported_versions": sorted(mod.supported_versions), - "valid": mod.valid, - "mtime": mod.mtime, - "about_rules": { - "load_before": sorted(str(x) for x in mod.about_rules.load_before), - "load_after": sorted(str(x) for x in mod.about_rules.load_after), - "incompatible_with": sorted( - str(x) for x in mod.about_rules.incompatible_with - ), - "dependencies": { - str(k): { - "name": d.name, - "package_id": str(d.package_id), - "workshop_url": d.workshop_url, - "alternative_package_ids": sorted( - str(a) for a in d.alternative_package_ids - ), - } - for k, d in mod.about_rules.dependencies.items() - }, - }, - } - - -def deserialize_mod(mod_path: Path, data: dict[str, Any]) -> AboutXmlMod: - mod = AboutXmlMod() - mod.mod_path = mod_path - mod.name = data.get("name", "Unknown Mod Name") - mod.package_id = CaseInsensitiveStr(data.get("package_id", "invalid.mod")) - mod.authors = list(data.get("authors", [])) - mod.mod_version = data.get("mod_version", "") - icon_str = data.get("mod_icon_path") - mod.mod_icon_path = Path(icon_str) if icon_str else None - mod.steam_app_id = data.get("steam_app_id", -1) - mod.url = data.get("url", "") - mod.description = data.get("description", "") - mod.supported_versions = set(data.get("supported_versions", [])) - mod.valid = data.get("valid", True) - mod.mtime = data.get("mtime", 0.0) - - rules_data = data.get("about_rules", {}) - rules = BaseRules() - rules.load_before = CaseInsensitiveSet(rules_data.get("load_before", [])) - rules.load_after = CaseInsensitiveSet(rules_data.get("load_after", [])) - rules.incompatible_with = CaseInsensitiveSet( - rules_data.get("incompatible_with", []) - ) - deps: dict[CaseInsensitiveStr, DependencyMod] = {} - for k, d in rules_data.get("dependencies", {}).items(): - dep_pid = CaseInsensitiveStr(d.get("package_id", k)) - deps[dep_pid] = DependencyMod( - name=d.get("name", ""), - package_id=dep_pid, - workshop_url=d.get("workshop_url", ""), - alternative_package_ids={ - CaseInsensitiveStr(a) for a in d.get("alternative_package_ids", []) - }, - ) - rules.dependencies = deps - mod.about_rules = rules - return mod - - -def _decode_rows( - rows: list[Any], - norm_map: dict[str, tuple[Path, Path, float, int]], - target_version: str, -) -> tuple[dict[Path, AboutXmlMod], list[tuple[Path, Path, float, int]]]: - cached_mods: dict[Path, AboutXmlMod] = {} - found_norms: set[str] = set() - for norm_p, _about_p, mtime, file_size, ver, payload in rows: - cand = norm_map.get(norm_p) - if cand is None: - continue - orig_mod_path, _orig_about_path, cur_mtime, cur_size = cand - if ( - abs(mtime - cur_mtime) < 1e-4 - and file_size == cur_size - and ver == target_version - ): - try: - mod = deserialize_mod(orig_mod_path, json.loads(payload)) - except (json.JSONDecodeError, KeyError) as e: - logger.warning("Corrupted metadata cache payload for {}: {}", norm_p, e) - continue - cached_mods[orig_mod_path] = mod - found_norms.add(norm_p) - missing = [c for n, c in norm_map.items() if n not in found_norms] - return cached_mods, missing - - -class MetadataCache: - """Persistent SQLite cache for parsed About.xml metadata. - - Owns a dedicated metadata-cache.db aiosqlite connection guarded by an asyncio lock. - """ - - __slots__ = ( - "_connection", - "_db_path", - "_disabled", - "_lock", - ) - - def __init__(self, path: Path) -> None: - self._db_path = path - self._connection: aiosqlite.Connection | None = None - self._disabled: bool = False - self._lock = asyncio.Lock() - - async def _get_connection(self) -> aiosqlite.Connection | None: - if self._disabled: - return None - - async with self._lock: - if self._connection is not None: - return self._connection - - self._db_path.parent.mkdir(parents=True, exist_ok=True) - db_str = str(self._db_path) - try: - conn = await self._open_and_init(db_str) - self._connection = conn - return conn - except (sqlite3.DatabaseError, aiosqlite.DatabaseError) as exc: - logger.warning( - "Corrupt metadata cache database at {}: {}. Attempting recovery.", - self._db_path, - exc, - ) - await self._recover_corrupt_db() - try: - conn = await self._open_and_init(db_str) - self._connection = conn - return conn - except (sqlite3.Error, aiosqlite.Error, OSError) as recover_exc: - logger.error( - "Failed to re-initialize metadata cache at {}: {}. " - "Disabling cache.", - self._db_path, - recover_exc, - ) - self._disabled = True - return None - except OSError as exc: - logger.warning( - "Metadata cache unavailable at {}: {}. Disabling cache.", - self._db_path, - exc, - ) - self._disabled = True - return None - - async def _open_and_init(self, db_str: str) -> aiosqlite.Connection: - conn = await aiosqlite.connect(db_str, check_same_thread=False) - try: - await conn.execute("PRAGMA journal_mode=WAL") - await conn.execute("PRAGMA busy_timeout=5000") - await conn.execute("PRAGMA foreign_keys=ON") - await conn.execute("PRAGMA auto_vacuum=INCREMENTAL") - - async def invalidate_metadata_payloads() -> None: - await conn.execute("DELETE FROM mod_metadata") - - await ensure_schema( - conn, - schema_sql=_SCHEMA, - schema_version=_SCHEMA_VERSION, - steps={2: invalidate_metadata_payloads}, - backup_path=self._db_path, - label="metadata cache schema", - ) - return conn - except BaseException: - await conn.close() - raise - - async def _recover_corrupt_db(self) -> None: - def _remove() -> None: - for p in ( - self._db_path, - self._db_path.with_name(f"{self._db_path.name}-wal"), - self._db_path.with_name(f"{self._db_path.name}-shm"), - ): - with contextlib.suppress(OSError): - if p.exists(): - p.unlink() - - await asyncio.to_thread(_remove) - - async def close(self) -> None: - async with self._lock: - if self._connection is not None: - await self._connection.close() - self._connection = None - - def close_sync(self) -> None: - if self._connection is not None: - self._connection._conn.close() - self._connection = None - - async def get_mods( - self, - candidates: list[tuple[Path, Path, float, int]], - target_version: str, - ) -> tuple[dict[Path, AboutXmlMod], list[tuple[Path, Path, float, int]]]: - """Look up candidate mods in cache. - - Returns (cached_mods_by_path, missing_or_stale_candidates). - """ - if not candidates: - return {}, [] - conn = await self._get_connection() - if conn is None: - return {}, list(candidates) - - norm_map = await asyncio.to_thread( - lambda: dict( - zip( - normalize_paths([c[0] for c in candidates]), candidates, strict=True - ) - ) - ) - - try: - async with self._lock: - rows = await self._fetch_candidates(conn, list(norm_map.keys())) - except (sqlite3.Error, aiosqlite.Error, OSError) as exc: - logger.warning("Error reading metadata cache: {}", exc) - return {}, list(candidates) - - return await asyncio.to_thread(_decode_rows, rows, norm_map, target_version) - - async def _fetch_candidates( - self, conn: aiosqlite.Connection, norm_paths: list[str] - ) -> list[Any]: - rows: list[Any] = [] - chunk_size = 500 - for i in range(0, len(norm_paths), chunk_size): - chunk = norm_paths[i : i + chunk_size] - placeholders = ",".join("?" for _ in chunk) - query = ( - "SELECT mod_path, about_path, mtime, file_size, target_version, " - f"payload_json FROM mod_metadata WHERE mod_path IN ({placeholders})" - ) - async with conn.execute(query, chunk) as cursor: - fetched = await cursor.fetchall() - rows.extend(fetched) - return rows - - async def put_mods( - self, - entries: list[tuple[AboutXmlMod, Path, float, int]], - target_version: str, - ) -> None: - """Store or update parsed metadata entries in the cache.""" - if not entries: - return - conn = await self._get_connection() - if conn is None: - return - - rows = [] - for mod, about_path, mtime, file_size in entries: - if mod.mod_path is None: - continue - norm_mod_path = normalize_path(mod.mod_path) - norm_about_path = normalize_path(about_path) - payload = json.dumps(serialize_mod(mod)) - rows.append( - ( - norm_mod_path, - norm_about_path, - mtime, - file_size, - target_version, - payload, - ) - ) - - try: - async with self._lock: - await conn.executemany( - "INSERT OR REPLACE INTO mod_metadata " - "(mod_path, about_path, mtime, file_size, target_version, " - "payload_json) VALUES (?, ?, ?, ?, ?, ?)", - rows, - ) - await conn.commit() - except (sqlite3.Error, aiosqlite.Error, OSError) as exc: - logger.warning("Error writing to metadata cache: {}", exc) - - async def prune_disappeared(self, live_mod_paths: set[str]) -> None: - """Remove entries for mods that no longer exist on disk.""" - conn = await self._get_connection() - if conn is None: - return - - try: - async with self._lock: - async with conn.execute("SELECT mod_path FROM mod_metadata") as cursor: - cached_rows = await cursor.fetchall() - cached_paths = {row[0] for row in cached_rows} - disappeared = cached_paths - live_mod_paths - if disappeared: - logger.debug( - "Pruning {} disappeared mods from metadata cache", - len(disappeared), - ) - await conn.executemany( - "DELETE FROM mod_metadata WHERE mod_path = ?", - [(p,) for p in disappeared], - ) - await conn.commit() - except (sqlite3.Error, aiosqlite.Error, OSError) as exc: - logger.warning("Error pruning metadata cache: {}", exc) diff --git a/src/pxmodrim/core/services/mod_discovery.py b/src/pxmodrim/core/services/mod_discovery.py deleted file mode 100644 index bdc189f..0000000 --- a/src/pxmodrim/core/services/mod_discovery.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path - -from loguru import logger - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.core.mods_config import parse_mods_config -from pxmodrim.core.utils import find_about_xml - - -def scan_mod_directory(mods_path: Path) -> dict[Path, Path]: - """Scan a directory for mod subdirectories, returning {mod_path: about_xml_path}.""" - if not mods_path.exists() or not mods_path.is_dir(): - logger.warning(f"Mod directory not found: {mods_path}") - return {} - - results: dict[Path, Path] = {} - with os.scandir(mods_path) as it: - for entry in it: - if not entry.is_dir(): - continue - p = Path(entry.path) - about = find_about_xml(p) - if about: - results[p] = about - return results - - -def resolve_active_uuids( - all_mods: dict[str, ListedMod], - config_folder: str, -) -> list[str]: - """Parse ModsConfig.xml and return ordered UUIDs matching active mods.""" - if not config_folder: - return [] - path = Path(config_folder) / "ModsConfig.xml" - data = parse_mods_config(path) - if not data: - return [] - - # Build map from normalized package_id -> UUID (first match for duplicates) - pid_to_uuid: dict[str, str] = {} - for uuid, mod in all_mods.items(): - if isinstance(mod, AboutXmlMod): - pid = mod.package_id.lower().removesuffix("_steam") - if pid not in pid_to_uuid: - pid_to_uuid[pid] = uuid - - # Iterate ModsConfig.xml activeMods IN ORDER, pick matching UUID - active: list[str] = [] - for pid in data.activeMods: - normalized = str(pid).removesuffix("_steam").lower() - if normalized in pid_to_uuid: - active.append(pid_to_uuid[normalized]) - return active diff --git a/src/pxmodrim/core/services/sort_service.py b/src/pxmodrim/core/services/sort_service.py deleted file mode 100644 index e6c005d..0000000 --- a/src/pxmodrim/core/services/sort_service.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -from pxmodrim.core.checker.sort import topological_sort -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.profiler import profile -from pxmodrim.core.sort.models import PackageId - -if TYPE_CHECKING: - from pxmodrim.core.services.diagnostics_service import DiagnosticsService - - -class SortService: - __slots__ = ("_ctx", "_diagnostics_service") - - def __init__(self, ctx: CoreContext, diagnostics_service: DiagnosticsService): - """Initialise with core context and diagnostics for topological sort.""" - self._ctx = ctx - self._diagnostics_service = diagnostics_service - - async def sort_active_mods(self) -> list[str]: - """Run topological sort on active mods in a thread, returning ordered UUIDs.""" - with profile("sort") as t: - active_mods = self._diagnostics_service.active_mods_by_pid - graph = self._diagnostics_service.constraint_graph - settings = self._ctx.config.sort - community_rules = self._diagnostics_service.community_rules - - def _sort() -> list[PackageId]: - return topological_sort( - active_mods, graph, settings, community_rules, timer=t - ) - - sorted_pids = await asyncio.to_thread(_sort) - - all_mods = self._ctx.all_mods - pid_to_uuid: dict[PackageId, str] = {} - for uuid in self._ctx.active_uuids: - mod = all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): - pid_to_uuid[PackageId(mod.package_id)] = uuid - - return [pid_to_uuid[pid] for pid in sorted_pids if pid in pid_to_uuid] - - def resolve_missing_dependencies(self, active_uuids: set[str]) -> list[str]: - """Find dependencies of active mods that exist as inactive mods. - - Returns UUIDs of inactive mods that should be enabled to satisfy - missing dependencies of the currently active set. - """ - pid_to_uuid: dict[str, str] = {} - for uuid, mod in self._ctx.all_mods.items(): - if isinstance(mod, AboutXmlMod): - pid_to_uuid[str(mod.package_id)] = uuid - - active_pids: set[str] = set() - for uuid in active_uuids: - mod = self._ctx.all_mods.get(uuid) - if isinstance(mod, AboutXmlMod): - active_pids.add(str(mod.package_id)) - - deps_to_enable: list[str] = [] - for uuid in active_uuids: - mod = self._ctx.all_mods.get(uuid) - if not isinstance(mod, AboutXmlMod): - continue - for dep_pid, dep_info in mod.about_rules.dependencies.items(): - dep_str = str(dep_pid) - if dep_str in active_pids: - continue - satisfied = any( - str(alt) in active_pids for alt in dep_info.alternative_package_ids - ) - if satisfied: - continue - dep_uuid = pid_to_uuid.get(dep_str) - if dep_uuid is not None and dep_uuid not in active_uuids: - deps_to_enable.append(dep_uuid) - - return list(dict.fromkeys(deps_to_enable)) diff --git a/src/pxmodrim/core/services/startup_impact_service/__init__.py b/src/pxmodrim/core/services/startup_impact_service/__init__.py deleted file mode 100644 index 8da10b3..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.core.services.startup_impact_service.models import ( - IMPACT_HIGH_THRESHOLD_S, - IMPACT_WARN_THRESHOLD_S, - StartupImpactMod, - StartupImpactReport, - format_impact, - normalize_package_id, -) -from pxmodrim.core.services.startup_impact_service.parser import ( - get_startup_impact_path, - parse_startup_impact, -) -from pxmodrim.core.services.startup_impact_service.service import ( - StartupImpactService, -) - -__all__ = [ - "IMPACT_HIGH_THRESHOLD_S", - "IMPACT_WARN_THRESHOLD_S", - "StartupImpactMod", - "StartupImpactReport", - "StartupImpactService", - "format_impact", - "get_startup_impact_path", - "metric_label", - "normalize_package_id", - "parse_startup_impact", -] diff --git a/src/pxmodrim/core/services/startup_impact_service/db.py b/src/pxmodrim/core/services/startup_impact_service/db.py deleted file mode 100644 index 602dc67..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/db.py +++ /dev/null @@ -1,445 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from pathlib import Path -from time import time - -import aiosqlite - -from pxmodrim.core.migrator import ensure_schema -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, -) - -HISTORY_DEPTH = 5 - -# Recency weights: the most recent session gets WEIGHT_SPREAD, the next -# WEIGHT_SPREAD-1, ... down to 1. Sessions older than that are clamped to 1. -WEIGHT_SPREAD = 10 - -_SCHEMA = """ - CREATE TABLE IF NOT EXISTS startup_impact_sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at REAL NOT NULL, - loading_time_s REAL NOT NULL DEFAULT 0, - timestamp TEXT NOT NULL DEFAULT '' - ); - - CREATE TABLE IF NOT EXISTS startup_impact_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id INTEGER NOT NULL, - package_id TEXT NOT NULL, - mod_name TEXT NOT NULL DEFAULT '', - total_impact_s REAL NOT NULL DEFAULT 0, - metrics_json TEXT NOT NULL DEFAULT '{}', - off_thread_metrics_json TEXT NOT NULL DEFAULT '{}', - off_thread_total_s REAL NOT NULL DEFAULT 0, - FOREIGN KEY (session_id) - REFERENCES startup_impact_sessions(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_sie_session - ON startup_impact_entries(session_id); - CREATE INDEX IF NOT EXISTS idx_sie_pid - ON startup_impact_entries(package_id); -""" - -_SCHEMA_VERSION = 1 - -# CTE ranking the most recent sessions by recency and assigning a descending -# weight (WEIGHT_SPREAD for the newest, WEIGHT_SPREAD-1 for the next, ... down -# to 1). Sessions older than WEIGHT_SPREAD clamp to weight 1. -# -# Bind parameters, in order: WEIGHT_SPREAD, HISTORY_DEPTH. -# Exposes columns: session_id, weight. -_WEIGHTED_SESSION_CTE = """ - ranked AS ( - SELECT id AS session_id, - ROW_NUMBER() OVER (ORDER BY id DESC) AS rn - FROM startup_impact_sessions - ORDER BY id DESC - LIMIT ? - ), - w AS ( - SELECT session_id, - MAX(? - rn + 1, 1) AS weight - FROM ranked - ) -""" - -_CTE_PARAMS: tuple[int, int] = (HISTORY_DEPTH, WEIGHT_SPREAD) - - -def db_path(config_dir: Path) -> Path: - return config_dir / "cache.db" - - -class StartupImpactDb: - """Owns a single aiosqlite connection guarded by an asyncio lock.""" - - __slots__ = ("_connection", "_connection_path", "_lock", "_operation_lock") - - def __init__(self) -> None: - self._connection: aiosqlite.Connection | None = None - self._connection_path: str = "" - self._lock = asyncio.Lock() - self._operation_lock = asyncio.Lock() - - async def _connection_for(self, path: Path) -> aiosqlite.Connection: - db_str = str(path) - async with self._lock: - if self._connection is None or self._connection_path != db_str: - if self._connection is not None: - await self._connection.close() - conn = await aiosqlite.connect(db_str, check_same_thread=False) - await conn.execute("PRAGMA journal_mode=WAL") - await conn.execute("PRAGMA busy_timeout=5000") - await conn.execute("PRAGMA foreign_keys=ON") - await conn.execute("PRAGMA auto_vacuum=INCREMENTAL") - await self._ensure_schema(conn, path) - self._connection = conn - self._connection_path = db_str - return self._connection - - async def _ensure_schema(self, conn: aiosqlite.Connection, path: Path) -> None: - await ensure_schema( - conn, - schema_sql=_SCHEMA, - schema_version=_SCHEMA_VERSION, - steps={ - # Add future migration steps here: - # 2: _v2_add_column, - }, - backup_path=path, - label="DB schema", - ) - - async def close(self) -> None: - async with self._operation_lock, self._lock: - if self._connection is not None: - await self._connection.close() - self._connection = None - self._connection_path = "" - - def close_sync(self) -> None: - if self._connection is not None: - self._connection._conn.close() - self._connection = None - self._connection_path = "" - - async def store_report(self, path: Path, report: StartupImpactReport) -> None: - async with self._operation_lock: - await self._store_report(path, report) - - async def _store_report(self, path: Path, report: StartupImpactReport) -> None: - if not path.exists(): - path.parent.mkdir(parents=True, exist_ok=True) - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "INSERT INTO startup_impact_sessions" - " (recorded_at, loading_time_s, timestamp) VALUES (?, ?, ?)", - (time(), report.loading_time_s, report.timestamp), - ) - sid = cursor.lastrowid - rows = [ - ( - sid, - mod.package_id or mod.mod_name, - mod.mod_name, - mod.total_impact_s, - json.dumps(mod.metrics), - json.dumps(mod.off_thread_metrics), - mod.off_thread_total_impact_s, - ) - for mod in report.mods - ] - await cursor.executemany( - "INSERT INTO startup_impact_entries" - " (session_id, package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s)" - " VALUES (?, ?, ?, ?, ?, ?, ?)", - rows, - ) - await cursor.execute( - "DELETE FROM startup_impact_sessions WHERE id NOT IN" - " (SELECT id FROM startup_impact_sessions ORDER BY id DESC" - " LIMIT ?)", - (HISTORY_DEPTH,), - ) - await db.commit() - - # Compact the database: checkpoint WAL, reclaim freelist, and VACUUM - # if the file has grown bloated (freelist pages accumulate because SQLite - # never shrinks the physical file by default). - await db.execute("PRAGMA wal_checkpoint(TRUNCATE)") - await db.execute("PRAGMA incremental_vacuum") - db_size = path.stat().st_size - if db_size > 10 * 1024 * 1024: - await db.execute("VACUUM") - - async def get_latest_report(self, path: Path) -> StartupImpactReport | None: - if not path.exists(): - return None - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "SELECT id, loading_time_s, timestamp" - " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1", - ) - row = await cursor.fetchone() - if not row: - return None - sid, loading_time_s, timestamp = row - await cursor.execute( - "SELECT package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s" - " FROM startup_impact_entries WHERE session_id = ?", - (sid,), - ) - rows = await cursor.fetchall() - mods = [_row_to_mod(r) for r in rows] - return StartupImpactReport( - path="", - loading_time_s=loading_time_s, - mods=tuple(mods), - timestamp=timestamp, - ) - - async def get_average(self, path: Path, package_id: str) -> float: - if not path.exists(): - return 0.0 - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT COALESCE( - SUM(e.total_impact_s * w.weight) * 1.0 - / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id = ? - """, - (*_CTE_PARAMS, package_id), - ) - row = await cursor.fetchone() - return float(row[0]) if row else 0.0 - - async def get_totals_for( - self, path: Path, package_ids: list[str] - ) -> dict[str, tuple[float, float]]: - """Return {package_id: (on_thread_est, off_thread_est)} for the most - recent sessions using recency-weighted averages, in a single query. - - Mods absent from the stored history default to (0.0, 0.0). - """ - result: dict[str, tuple[float, float]] = dict.fromkeys(package_ids, (0.0, 0.0)) - if not package_ids: - return result - if not path.exists(): - return result - placeholders = ",".join("?" for _ in package_ids) - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT e.package_id, - COALESCE( - SUM(e.total_impact_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ), - COALESCE( - SUM(e.off_thread_total_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id IN (""" - + placeholders - + """) - GROUP BY e.package_id - """, - (*_CTE_PARAMS, *package_ids), - ) - for pid, on_thread, off_thread in await cursor.fetchall(): - result[str(pid)] = (float(on_thread), float(off_thread)) - return result - - async def get_all_averages(self, path: Path) -> dict[str, float]: - if not path.exists(): - return {} - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT e.package_id, - COALESCE( - SUM(e.total_impact_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - GROUP BY e.package_id - """, - _CTE_PARAMS, - ) - return {str(row[0]): float(row[1]) for row in await cursor.fetchall()} - - async def get_latest_with_averages( - self, - path: Path, - active_pids: list[str], - selected_pid: str | None = None, - ) -> tuple[ - StartupImpactReport | None, - float, - dict[str, tuple[float, float]], - float, - ]: - """Fetch the latest report plus base-game and per-mod impact averages in - a single connection/transaction. - - Returns (latest_report, base_game_avg, pid_totals, selected_mod_avg) - where pid_totals maps each active pid to (on_thread_avg, off_thread_avg) - and selected_mod_avg is the on-thread average for the selected mod (0.0 - if none/absent). - """ - if not path.exists(): - return None, 0.0, dict.fromkeys(active_pids, (0.0, 0.0)), 0.0 - - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute( - "SELECT id, loading_time_s, timestamp" - " FROM startup_impact_sessions ORDER BY id DESC LIMIT 1", - ) - row = await cursor.fetchone() - report: StartupImpactReport | None = None - if row is not None: - sid, loading_time_s, timestamp = row - await cursor.execute( - "SELECT package_id, mod_name, total_impact_s," - " metrics_json, off_thread_metrics_json, off_thread_total_s" - " FROM startup_impact_entries WHERE session_id = ?", - (sid,), - ) - mods = [_row_to_mod(r) for r in await cursor.fetchall()] - report = StartupImpactReport( - path="", - loading_time_s=loading_time_s, - mods=tuple(mods), - timestamp=timestamp, - ) - - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT COALESCE( - SUM(e.total_impact_s * w.weight) * 1.0 - / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id = ? - """, - (*_CTE_PARAMS, BASE_GAME_PID), - ) - base_row = await cursor.fetchone() - base_avg = float(base_row[0]) if base_row else 0.0 - - totals: dict[str, tuple[float, float]] = dict.fromkeys( - active_pids, (0.0, 0.0) - ) - if active_pids: - placeholders = ",".join("?" for _ in active_pids) - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT e.package_id, - COALESCE( - SUM(e.total_impact_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ), - COALESCE( - SUM(e.off_thread_total_s * w.weight) - * 1.0 / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id IN (""" - + placeholders - + """) - GROUP BY e.package_id - """, - (*_CTE_PARAMS, *active_pids), - ) - for pid, on_thread, off_thread in await cursor.fetchall(): - totals[str(pid)] = (float(on_thread), float(off_thread)) - - selected_avg = 0.0 - if selected_pid is not None: - await cursor.execute( - "WITH " - + _WEIGHTED_SESSION_CTE - + """ - SELECT COALESCE( - SUM(e.total_impact_s * w.weight) * 1.0 - / NULLIF(SUM(w.weight), 0), - 0 - ) - FROM startup_impact_entries e - JOIN w ON w.session_id = e.session_id - WHERE e.package_id = ? - """, - (*_CTE_PARAMS, selected_pid), - ) - sel_row = await cursor.fetchone() - selected_avg = float(sel_row[0]) if sel_row else 0.0 - - return report, base_avg, totals, selected_avg - - async def clear(self, path: Path) -> None: - async with self._operation_lock: - if not path.exists(): - return - db = await self._connection_for(path) - async with db.cursor() as cursor: - await cursor.execute("DELETE FROM startup_impact_entries") - await cursor.execute("DELETE FROM startup_impact_sessions") - await db.commit() - await db.execute("VACUUM") - await db.execute("PRAGMA wal_checkpoint(TRUNCATE)") - - -def _row_to_mod(row: aiosqlite.Row) -> StartupImpactMod: - pid, name, impact, metrics_json, ot_json, ot_total = row - pid_val: str | None = pid - if pid == name and pid != BASE_GAME_PID: - pid_val = None - return StartupImpactMod( - mod_name=name, - package_id=pid_val, - total_impact_s=impact, - metrics=json.loads(metrics_json or "{}"), - off_thread_metrics=json.loads(ot_json or "{}"), - off_thread_total_impact_s=ot_total, - ) diff --git a/src/pxmodrim/core/services/startup_impact_service/labels.py b/src/pxmodrim/core/services/startup_impact_service/labels.py deleted file mode 100644 index cf49ef0..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/labels.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -METRIC_LABELS: dict[str, str] = { - "ModConstructor": "Running mod constructor", - "LoadDefs": "Loading defs", - "CombineXml": "Combining XML into a single XML document", - "TKeySystemParse": "Translation key system parsing", - "LoadedModManagerParseAndProcessXML": "Parsing and processing XML", - "ErrorCheckPatches": "Error checking patches", - "LoadPatches": "Loading patches", - "ApplyPatches": "Applying patches", - "RegisterXmlInheritance": "Registering XML inheritance", - "ResolveXmlInheritance": "Resolving XML inheritance", - "ClearCachedPatches": "Clearing cached patches", - "ClearCachedXmlInheritance": "Clearing cached XML inheritance", - "LanguageDatabaseInitAllMetadata": "Loading language metadata", - "DefDatabaseAddAllInMods": "Copying all Defs from mods to global databases", - "ResolveAllWantedCrossReferences": "Resolving cross-references", - "ResolveAllWantedCrossReferences.NonImplied": "Resolving cross-references between non-implied Defs", # noqa: E501 - "DefOfHelperRebindAllDefOfs": "Rebinding DefOfs", - "DefOfHelperRebindAllDefOfs.Early": "Rebinding DefOfs (early)", - "DefOfHelperRebindAllDefOfs.Final": "Rebinding DefOfs (final)", - "TKeySystemBuildMappings": "Translation key system mapping", - "BackStoryTranslationUtilityLoadAndInjectBackstoryData": "Loading and injecting legacy backstory translations", # noqa: E501 - "ModContentPackReloadContentInt": "Loading content", - "ModContentPackReloadContentInt.AudioClips": "Loading audio clips", - "ModContentPackReloadContentInt.Textures": "Loading textures", - "ModContentPackReloadContentInt.Strings": "Loading strings", - "ModContentPackReloadContentInt.AssetBundles": "Loading asset bundles", - "LoadedLanguageInjectIntoDataBeforeImpliedDefs": "Injecting language data (early pass)", # noqa: E501 - "ColoredTextResetStaticData": "Running global operations (early pass)", - "DefGeneratorGenerateImpliedDefsPreResolve": "Generating implied Defs (pre-resolve)", # noqa: E501 - "ResolveAllWantedCrossReferences.Implied": "Resolving cross-references between implied Defs", # noqa: E501 - "PlayDataLoaderResetStaticDataPre": "Global operations (pre-resolve)", - "ResolveReferences": "Resolving references", - "DefGeneratorGenerateImpliedDefsPostResolve": "Generating implied Defs (post-resolve)", # noqa: E501 - "PlayDataLoaderResetStaticDataPost": "Global operations (post-resolve)", - "ErrorCheckAllDefs": "Error checking all defs", - "KeyPrefsInit": "Loading keyboard preferences", - "ShortHashGiverGiveAllShortHashes": "Short hash giving", - "ExecuteToExecuteWhenFinished": "Running delayed initialization task", - "SolidBioDatabaseLoadAllBios": "Loading all bios", - "LoadedLanguageInjectIntoDataAfterImpliedDefs": "Injecting language data", - "StaticConstructorOnStartupUtilityCallAll": "Running static constructors", - "FloatMenuMakerMapInit": "Initializing float menu maker map", - "GlobalTextureAtlasManagerBakeStaticAtlases": "Texture atlas baking", - "AbstractFilesystemClearAllCache": "Clearing filesystem cache", - "LoadModXml": "Loading and parsing mod XML", -} - - -_PREFIX = "LoadingProgress.StartupImpact." - - -def strip_metric_prefix(key: str) -> str: - return key.removeprefix(_PREFIX) - - -def metric_label(key: str) -> str: - key = strip_metric_prefix(key) - key, _, target = key.partition("|") - label = _base_label(key) - if target: - return f"{label} ({target.split(' -> ', 1)[0]})" - return label - - -def _base_label(key: str) -> str: - if key in METRIC_LABELS: - return METRIC_LABELS[key] - tail = key.split(".")[-1] - if tail in METRIC_LABELS: - return METRIC_LABELS[tail] - return key diff --git a/src/pxmodrim/core/services/startup_impact_service/models.py b/src/pxmodrim/core/services/startup_impact_service/models.py deleted file mode 100644 index 1bf79c8..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/models.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field - -IMPACT_WARN_THRESHOLD_S = 1.0 -IMPACT_HIGH_THRESHOLD_S = 5.0 -BASE_GAME_PID = "__base_game__" - - -@dataclass(frozen=True, slots=True) -class StartupImpactMod: - mod_name: str - package_id: str | None - total_impact_s: float - metrics: dict[str, float] = field(default_factory=dict) - off_thread_metrics: dict[str, float] = field(default_factory=dict) - off_thread_total_impact_s: float = 0.0 - - -@dataclass(frozen=True, slots=True) -class StartupImpactReport: - path: str - loading_time_s: float - mods: tuple[StartupImpactMod, ...] - timestamp: str = "" - metrics: dict[str, float] = field(default_factory=dict) - total_impact: float = 0.0 - off_thread_metrics: dict[str, float] = field(default_factory=dict) - off_thread_total_impact: float = 0.0 - - @property - def total_time(self) -> float: - return sum(m.total_impact_s for m in self.mods) - - @property - def off_thread_total_time(self) -> float: - return sum(m.off_thread_total_impact_s for m in self.mods) - - @property - def mods_total_time(self) -> float: - return self.total_time + self.off_thread_total_time - - @property - def base_game_time(self) -> float: - return sum(self.metrics.values()) - - def find( - self, package_id: str | None, mod_name: str | None - ) -> StartupImpactMod | None: - if package_id: - normalized = normalize_package_id(package_id) - for mod in self.mods: - if mod.package_id == normalized: - return mod - if mod_name: - name_lower = mod_name.lower() - for mod in self.mods: - if mod.mod_name.lower() == name_lower: - return mod - return None - - -def normalize_package_id(package_id: str) -> str: - return package_id.lower().removesuffix("_steam") - - -def format_impact(seconds: float) -> str: - return f"{round(seconds * 1000)}ms" diff --git a/src/pxmodrim/core/services/startup_impact_service/parser.py b/src/pxmodrim/core/services/startup_impact_service/parser.py deleted file mode 100644 index 46e8ab8..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/parser.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from loguru import logger - -from pxmodrim.core.services.startup_impact_service.models import ( - StartupImpactMod, - StartupImpactReport, - normalize_package_id, -) - -_STARTUP_IMPACT_FILENAME = "StartupImpactData.json" - - -def get_startup_impact_path(config_folder: str | Path) -> Path: - return Path(config_folder).parent / _STARTUP_IMPACT_FILENAME - - -def parse_startup_impact(path: str | Path) -> StartupImpactReport | None: - p = Path(path) - if not p.exists(): - logger.debug("Startup impact report not found at {}", p) - return None - - try: - data = json.loads(p.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError) as e: - logger.warning("Failed to parse startup impact report at {}: {}", p, e) - return None - - if not isinstance(data, dict): - logger.warning("Startup impact report is not a JSON object: {}", p) - return None - - mods: list[StartupImpactMod] = [] - for entry in data.get("mods") or (): - if not isinstance(entry, dict): - continue - mod_name = entry.get("modName") - if not isinstance(mod_name, str) or not mod_name: - continue - - raw_package_id = entry.get("modPackageId") - package_id = ( - normalize_package_id(raw_package_id) - if isinstance(raw_package_id, str) and raw_package_id - else None - ) - - mods.append( - StartupImpactMod( - mod_name=mod_name, - package_id=package_id, - total_impact_s=_as_seconds(entry.get("totalImpact")), - metrics=_as_string_float_dict(entry.get("metrics")), - off_thread_metrics=_as_string_float_dict(entry.get("offThreadMetrics")), - off_thread_total_impact_s=_as_seconds( - entry.get("offThreadTotalImpact") - ), - ) - ) - - return StartupImpactReport( - path=str(p), - loading_time_s=_as_seconds(data.get("loadingTime")), - mods=tuple(mods), - timestamp=data.get("timestamp") or "", - metrics=_as_string_float_dict(data.get("metrics")), - total_impact=_as_seconds(data.get("totalImpact")), - off_thread_metrics=_as_string_float_dict(data.get("offThreadMetrics")), - off_thread_total_impact=_as_seconds(data.get("offThreadTotalImpact")), - ) - - -def _as_seconds(value: object) -> float: - if isinstance(value, (int, float)): - return float(value) / 1000.0 - if isinstance(value, str): - try: - return float(value) / 1000.0 - except ValueError: - return 0.0 - return 0.0 - - -def _as_string_float_dict(value: object) -> dict[str, float]: - if not isinstance(value, dict): - return {} - return { - str(k): float(v) / 1000.0 - for k, v in value.items() - if isinstance(v, (int, float)) - } diff --git a/src/pxmodrim/core/services/startup_impact_service/phases.py b/src/pxmodrim/core/services/startup_impact_service/phases.py deleted file mode 100644 index 4a0061a..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/phases.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -from enum import StrEnum - -from pxmodrim.core.services.startup_impact_service.labels import strip_metric_prefix - - -class Phase(StrEnum): - CONTENT = "content" - MOD_CONSTRUCTORS = "mod_constructors" - STATIC_CONSTRUCTORS = "static_constructors" - PATCHES = "patches" - DEFS = "defs" - DEFERRED = "deferred" - OTHER = "other" - - -PHASE_LABELS: dict[Phase, str] = { - Phase.CONTENT: "Textures & audio", - Phase.MOD_CONSTRUCTORS: "Mod constructors", - Phase.STATIC_CONSTRUCTORS: "Static constructors", - Phase.PATCHES: "Patches", - Phase.DEFS: "Defs & XML", - Phase.DEFERRED: "Deferred init", - Phase.OTHER: "Other", -} - -_EXACT: dict[str, Phase] = { - "GlobalTextureAtlasManagerBakeStaticAtlases": Phase.CONTENT, - "ModConstructor": Phase.MOD_CONSTRUCTORS, - "StaticConstructorOnStartupUtilityCallAll": Phase.STATIC_CONSTRUCTORS, - "ExecuteToExecuteWhenFinished": Phase.DEFERRED, - "LoadDefs": Phase.DEFS, - "LoadModXml": Phase.DEFS, - "CombineXml": Phase.DEFS, - "LoadedModManagerParseAndProcessXML": Phase.DEFS, - "RegisterXmlInheritance": Phase.DEFS, - "ResolveXmlInheritance": Phase.DEFS, - "ClearCachedXmlInheritance": Phase.DEFS, - "DefDatabaseAddAllInMods": Phase.DEFS, - "ResolveReferences": Phase.DEFS, - "ErrorCheckAllDefs": Phase.DEFS, - "ShortHashGiverGiveAllShortHashes": Phase.DEFS, -} - -_PREFIXES: tuple[tuple[str, Phase], ...] = ( - ("ModContentPackReloadContentInt", Phase.CONTENT), - ("ResolveAllWantedCrossReferences", Phase.DEFS), - ("DefGeneratorGenerateImpliedDefs", Phase.DEFS), - ("DefOfHelperRebindAllDefOfs", Phase.DEFS), -) - - -def metric_phase(key: str) -> Phase: - name = strip_metric_prefix(key).split("|", 1)[0] - if phase := _EXACT.get(name): - return phase - if name.endswith("Patches"): - return Phase.PATCHES - for prefix, phase in _PREFIXES: - if name.startswith(prefix): - return phase - return Phase.OTHER - - -def phase_totals(metrics: dict[str, float]) -> dict[Phase, float]: - totals = dict.fromkeys(Phase, 0.0) - for key, seconds in metrics.items(): - totals[metric_phase(key)] += seconds - return totals diff --git a/src/pxmodrim/core/services/startup_impact_service/service.py b/src/pxmodrim/core/services/startup_impact_service/service.py deleted file mode 100644 index 581270f..0000000 --- a/src/pxmodrim/core/services/startup_impact_service/service.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from loguru import logger - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.services.startup_impact_service import db -from pxmodrim.core.services.startup_impact_service.db import StartupImpactDb -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, - normalize_package_id, -) -from pxmodrim.core.services.startup_impact_service.parser import ( - get_startup_impact_path, - parse_startup_impact, -) - - -class StartupImpactService: - """Loads startup impact data into DB and provides async queries for - historical averages and per-session metric breakdowns.""" - - __slots__ = ("_ctx", "_db") - - def __init__(self, ctx: CoreContext) -> None: - self._ctx = ctx - self._db = StartupImpactDb() - - async def load(self) -> StartupImpactReport | None: - cfg = self._ctx.config - if not cfg.paths.config_folder: - return None - - dbp = db.db_path(self._ctx.config_service.config_dir) - json_path = get_startup_impact_path(Path(cfg.paths.config_folder)) - logger.debug("Loading startup impact from {}", json_path) - - report = parse_startup_impact(json_path) - if report: - base = max(0.0, report.base_game_time) - entries: list[StartupImpactMod] = list(report.mods) - if base > 0.001: - entries.append( - StartupImpactMod( - mod_name=BASE_GAME_PID, - package_id=BASE_GAME_PID, - total_impact_s=base, - metrics=report.metrics, - off_thread_metrics=report.off_thread_metrics, - off_thread_total_impact_s=sum( - report.off_thread_metrics.values() - ), - ) - ) - full = StartupImpactReport( - path=report.path, - loading_time_s=report.loading_time_s, - mods=tuple(entries), - timestamp=report.timestamp, - ) - await self._db.store_report(dbp, full) - - return report - - async def get_latest_report(self) -> StartupImpactReport | None: - return await self._db.get_latest_report( - db.db_path(self._ctx.config_service.config_dir) - ) - - async def estimated_impact_for(self, package_id: str) -> float: - return await self._db.get_average( - db.db_path(self._ctx.config_service.config_dir), - normalize_package_id(package_id), - ) - - async def estimated_total(self, active_pids: list[str]) -> float: - dbp = db.db_path(self._ctx.config_service.config_dir) - bg = await self._db.get_average(dbp, BASE_GAME_PID) - totals = await self._db.get_totals_for( - dbp, [normalize_package_id(p) for p in active_pids] - ) - mod_sum = sum(on + off for on, off in totals.values()) - return bg + mod_sum - - async def get_all_averages(self) -> dict[str, float]: - return await self._db.get_all_averages( - db.db_path(self._ctx.config_service.config_dir) - ) - - async def base_game_average(self) -> float: - return await self._db.get_average( - db.db_path(self._ctx.config_service.config_dir), BASE_GAME_PID - ) - - async def snapshot( - self, active_pids: list[str], selected_pid: str | None = None - ) -> tuple[ - StartupImpactReport | None, - float, - dict[str, tuple[float, float]], - float, - ]: - """Single round-trip fetch of the latest report plus base-game average, - per-mod on/off-thread averages, and the selected mod's average. Replaces - the four sequential queries previously issued on every mod toggle. - """ - return await self._db.get_latest_with_averages( - db.db_path(self._ctx.config_service.config_dir), - [normalize_package_id(p) for p in active_pids], - normalize_package_id(selected_pid) if selected_pid else None, - ) - - async def clear(self) -> None: - await self._db.clear(db.db_path(self._ctx.config_service.config_dir)) - - async def close_connection(self) -> None: - await self._db.close() - - def close_connection_sync(self) -> None: - self._db.close_sync() diff --git a/src/pxmodrim/core/services/update_service.py b/src/pxmodrim/core/services/update_service.py deleted file mode 100644 index d4a46e8..0000000 --- a/src/pxmodrim/core/services/update_service.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -import re -from dataclasses import dataclass - -import httpx -from loguru import logger - -LATEST_RELEASE_URL = "https://api.github.com/repos/PyXiion/PxModRim/releases/latest" - -_TIMEOUT_SECONDS = 10.0 -_VERSION_RE = re.compile(r"^v?(\d+(?:\.\d+)*)([^+]*)") - - -class UpdateCheckError(Exception): - """The latest-release lookup failed (network, HTTP status or bad payload).""" - - -@dataclass(frozen=True, slots=True) -class ReleaseInfo: - tag: str - name: str - notes: str - url: str - - -def _split_version(text: str) -> tuple[tuple[int, ...], str] | None: - match = _VERSION_RE.match(text.strip()) - if match is None: - return None - numbers = tuple(int(part) for part in match.group(1).split(".")) - return numbers, match.group(2).strip("-._ ").lower() - - -def parse_version(text: str) -> tuple[int, ...] | None: - """Parse ``v1.2.3``-style text into a numeric tuple, ignoring any suffix.""" - parsed = _split_version(text) - return None if parsed is None else parsed[0] - - -def is_newer(tag: str, current: str) -> bool: - """Return True when *tag* is a strictly higher release than *current*. - - A pre-release suffix (``-rc1``, ``-beta.1``) sorts below the plain release - with the same numbers. Unparseable versions (e.g. an unknown dev build) are - never reported as outdated, so a broken tag cannot nag the user. - """ - latest = _split_version(tag) - running = _split_version(current) - if latest is None or running is None: - return False - width = max(len(latest[0]), len(running[0])) - - def key(parsed: tuple[tuple[int, ...], str]) -> tuple[tuple[int, ...], bool, str]: - numbers, suffix = parsed - padded = numbers + (0,) * (width - len(numbers)) - return padded, not suffix, suffix - - return key(latest) > key(running) - - -class UpdateService: - """Looks up the newest published GitHub release.""" - - __slots__ = ("_current", "_transport") - - def __init__( - self, - current_version: str, - transport: httpx.AsyncBaseTransport | None = None, - ) -> None: - self._current = current_version - self._transport = transport - - @property - def current_version(self) -> str: - return self._current - - async def check(self) -> ReleaseInfo | None: - """Return the latest release if newer than the running app, else None. - - Raises ``UpdateCheckError`` on network failure, a non-success HTTP - status or a malformed response. - """ - try: - async with httpx.AsyncClient( - timeout=_TIMEOUT_SECONDS, - headers={ - "Accept": "application/vnd.github+json", - "User-Agent": f"PxModRim/{self._current}", - }, - transport=self._transport, - ) as client: - response = await client.get(LATEST_RELEASE_URL) - response.raise_for_status() - data = response.json() - except (httpx.HTTPError, ValueError) as exc: - raise UpdateCheckError(str(exc) or type(exc).__name__) from exc - - if not isinstance(data, dict): - raise UpdateCheckError("Latest release response is not a JSON object") - tag = str(data.get("tag_name") or "") - url = str(data.get("html_url") or "") - if not tag or not url: - raise UpdateCheckError("Latest release response lacks tag_name/html_url") - if not is_newer(tag, self._current): - logger.debug("No update: latest {} vs running {}", tag, self._current) - return None - logger.info("Update available: {} (running {})", tag, self._current) - return ReleaseInfo( - tag=tag, - name=str(data.get("name") or tag), - notes=str(data.get("body") or ""), - url=url, - ) diff --git a/src/pxmodrim/core/sort/__init__.py b/src/pxmodrim/core/sort/__init__.py deleted file mode 100644 index 6e46587..0000000 --- a/src/pxmodrim/core/sort/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.sort.community import ( - community_rules_path, - load_community_rules, -) -from pxmodrim.core.sort.community_service import CommunityRulesService -from pxmodrim.core.sort.config import ( - SortSettings, - Tier, - TierConfig, -) -from pxmodrim.core.sort.exceptions import CommunityRulesMissingError -from pxmodrim.core.sort.models import ( - CommunityRule, - PackageId, -) -from pxmodrim.core.sort.tiers import ( - assign_tiers, - find_cycle, - get_deps_recursive, - get_reverse_deps_recursive, -) - -__all__ = [ - "CommunityRule", - "CommunityRulesMissingError", - "CommunityRulesService", - "PackageId", - "SortSettings", - "Tier", - "TierConfig", - "assign_tiers", - "community_rules_path", - "find_cycle", - "get_deps_recursive", - "get_reverse_deps_recursive", - "load_community_rules", -] diff --git a/src/pxmodrim/core/sort/community.py b/src/pxmodrim/core/sort/community.py deleted file mode 100644 index 6dbe6ef..0000000 --- a/src/pxmodrim/core/sort/community.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -from typing import Any - -import msgspec - -from pxmodrim.core.msgspec_hooks import dec_hook -from pxmodrim.core.sort.models import CommunityRule, PackageId - -COMMUNITY_RULES_URL = ( - "https://github.com/RimSort/Community-Rules-Database/archive/refs/heads/main.zip" -) -COMMUNITY_RULES_FILENAME = "communityRules.json" - - -def config_dir() -> Path: - base = os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config") - return Path(base) / "pxmodrim" - - -def community_rules_path() -> Path: - return config_dir() / COMMUNITY_RULES_FILENAME - - -class ExternalRule(msgspec.Struct, omit_defaults=True): - """A single community rule specifying load-ordering relationships for a mod.""" - - loadAfter: dict[str, Any] = {} - loadBefore: dict[str, Any] = {} - loadTop: SubExternalBoolRule = msgspec.field( - default_factory=lambda: SubExternalBoolRule() - ) - loadBottom: SubExternalBoolRule = msgspec.field( - default_factory=lambda: SubExternalBoolRule() - ) - - -class SubExternalBoolRule(msgspec.Struct, omit_defaults=True): - value: bool = False - - -class ExternalRulesSchema(msgspec.Struct, omit_defaults=True): - """Schema for the community rules JSON file, wrapping a timestamp and rule map.""" - - timestamp: int = 0 - rules: dict[str, ExternalRule] = {} - - def to_community_rules(self) -> dict[PackageId, CommunityRule]: - result: dict[PackageId, CommunityRule] = {} - for pid_str, rule in self.rules.items(): - pid = PackageId(pid_str) - result[pid] = CommunityRule( - package_id=pid, - load_after={PackageId(x) for x in rule.loadAfter}, - load_before={PackageId(x) for x in rule.loadBefore}, - load_first=rule.loadTop.value, - load_last=rule.loadBottom.value, - incompatible_with=set(), - ) - return result - - -def load_community_rules(json_path: Path) -> dict[PackageId, CommunityRule]: - """Load and deserialize community rules from a JSON file on disk.""" - if not json_path.exists(): - return {} - data = msgspec.json.decode( - json_path.read_bytes(), type=ExternalRulesSchema, dec_hook=dec_hook - ) - return data.to_community_rules() diff --git a/src/pxmodrim/core/sort/community_service.py b/src/pxmodrim/core/sort/community_service.py deleted file mode 100644 index d30d944..0000000 --- a/src/pxmodrim/core/sort/community_service.py +++ /dev/null @@ -1,99 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -import zipfile -from pathlib import Path -from typing import TYPE_CHECKING - -import httpx -import msgspec - -from pxmodrim.core.loading import LoadingState -from pxmodrim.core.sort.community import ( - COMMUNITY_RULES_FILENAME, - COMMUNITY_RULES_URL, - ExternalRulesSchema, - dec_hook, -) -from pxmodrim.core.sort.models import CommunityRule, PackageId - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - - -class CommunityRulesService: - """Service for downloading and loading community rules.""" - - __slots__ = ("_cache_dir", "_json_path") - - def __init__(self, config_service: ConfigService) -> None: - self._cache_dir = config_service.config_dir - self._json_path = self._cache_dir / COMMUNITY_RULES_FILENAME - - async def ensure_rules( - self, loading: LoadingState, force: bool = False - ) -> Path | None: - """Ensure community rules file exists, downloading if needed.""" - if self._json_path.exists() and not force: - return self._json_path - return await self._download_rules(loading) - - async def _download_rules(self, loading: LoadingState) -> Path | None: - self._cache_dir.mkdir(parents=True, exist_ok=True) - - zip_path = self._cache_dir / "community_rules.zip" - extract_dir = self._cache_dir / "community_rules_extracted" - - try: - async with httpx.AsyncClient() as client: - head = await client.head( - COMMUNITY_RULES_URL, follow_redirects=True, timeout=10.0 - ) - total_size = int(head.headers.get("content-length", 0)) or 100 - - async with client.stream( - "GET", COMMUNITY_RULES_URL, follow_redirects=True, timeout=30.0 - ) as resp: - resp.raise_for_status() - - with loading.task( - "Downloading community rules...", total_steps=total_size - ) as task: - fh = await asyncio.to_thread(zip_path.open, "wb") - try: - async for chunk in resp.aiter_bytes(8192): - await asyncio.to_thread(fh.write, chunk) - task.step(len(chunk)) - finally: - await asyncio.to_thread(fh.close) - - with loading.task("Extracting...", total_steps=100) as task: - with zipfile.ZipFile(zip_path, "r") as zf: - zf.extractall(extract_dir) - task.step(50) - for root, _dirs, files in os.walk(extract_dir): - for filename in files: - if filename == COMMUNITY_RULES_FILENAME: - src_path = Path(str(root)) / filename - src_path.replace(self._json_path) - task.step(50) - return self._json_path - - except (OSError, zipfile.BadZipFile, httpx.HTTPError) as e: - import logging - - logging.getLogger(__name__).warning( - f"Failed to download community rules: {e}" - ) - - return None - - def load_rules(self) -> dict[PackageId, CommunityRule]: - """Load community rules from cached file.""" - if not self._json_path.exists(): - return {} - data = msgspec.json.decode( - self._json_path.read_bytes(), type=ExternalRulesSchema, dec_hook=dec_hook - ) - return data.to_community_rules() diff --git a/src/pxmodrim/core/sort/config.py b/src/pxmodrim/core/sort/config.py deleted file mode 100644 index 355e290..0000000 --- a/src/pxmodrim/core/sort/config.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import IntEnum - -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr - -PackageId = CaseInsensitiveStr - - -class Tier(IntEnum): - TIER_0 = 0 - TIER_1 = 1 - TIER_2 = 2 - TIER_3 = 3 - - -@dataclass(frozen=True, slots=True) -class TierConfig: - tier_0: tuple[PackageId, ...] - tier_1: tuple[PackageId, ...] - tier_3: tuple[PackageId, ...] - - @staticmethod - def default() -> TierConfig: - return TierConfig( - tier_0=( - PackageId("zetrith.prepatcher"), - PackageId("brrainz.harmony"), - PackageId("brrainz.visualexceptions"), - PackageId("ludeon.rimworld"), - PackageId("ludeon.rimworld.royalty"), - PackageId("ludeon.rimworld.ideology"), - PackageId("ludeon.rimworld.biotech"), - PackageId("ludeon.rimworld.anomaly"), - PackageId("ludeon.rimworld.odyssey"), - ), - tier_1=( - PackageId("adaptive.storage.framework"), - PackageId("aoba.framework"), - PackageId("aoba.exosuit.framework"), - PackageId("ebsg.framework"), - PackageId("imranfish.xmlextensions"), - PackageId("thesepeople.ritualattachableoutcomes"), - PackageId("ohno.asf.ab.local"), - PackageId("oskarpotocki.vanillafactionsexpanded.core"), - PackageId("owlchemist.cherrypicker"), - PackageId("redmattis.betterprerequisites"), - PackageId("smashphil.vehicleframework"), - PackageId("unlimitedhugs.hugslib"), - PackageId("vanillaexpanded.backgrounds"), - ), - tier_3=(), - ) - - -@dataclass(frozen=True, slots=True) -class SortSettings: - use_alternative_package_ids: bool = True - check_missing_dependencies: bool = True - use_community_rules: bool = True - tier_config: TierConfig = field(default_factory=TierConfig.default) diff --git a/src/pxmodrim/core/sort/exceptions.py b/src/pxmodrim/core/sort/exceptions.py deleted file mode 100644 index 45e0b5e..0000000 --- a/src/pxmodrim/core/sort/exceptions.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -class CommunityRulesMissingError(Exception): - def __init__(self, path: Path): - self.path = path - super().__init__(f"Community rules file not found: {path}") diff --git a/src/pxmodrim/core/sort/models.py b/src/pxmodrim/core/sort/models.py deleted file mode 100644 index fd7cdb0..0000000 --- a/src/pxmodrim/core/sort/models.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass - -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr - -PackageId = CaseInsensitiveStr - - -@dataclass(frozen=True, slots=True) -class CommunityRule: - package_id: PackageId - load_after: set[PackageId] - load_before: set[PackageId] - load_first: bool - load_last: bool - incompatible_with: set[PackageId] diff --git a/src/pxmodrim/core/sort/progress.py b/src/pxmodrim/core/sort/progress.py deleted file mode 100644 index ee79406..0000000 --- a/src/pxmodrim/core/sort/progress.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from contextlib import contextmanager -from typing import Protocol, Self - - -class ProgressReporter(Protocol): - """Minimal progress-reporting interface consumed by sort-layer operations.""" - - @contextmanager - def task( - self, status: str, total_steps: int = 100 - ) -> Generator[Self, None, None]: ... - - def step(self, amount: int = 1) -> None: ... diff --git a/src/pxmodrim/core/sort/tiers.py b/src/pxmodrim/core/sort/tiers.py deleted file mode 100644 index 3b94d9b..0000000 --- a/src/pxmodrim/core/sort/tiers.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.sort.config import PackageId, Tier, TierConfig - - -def assign_tiers( - canonical_ids: set[PackageId], - deps: dict[PackageId, set[PackageId]], - rev_deps: dict[PackageId, set[PackageId]], - load_first: set[PackageId], - load_last: set[PackageId], - tier_config: TierConfig, -) -> dict[PackageId, Tier]: - """Assign each mod to a Tier (0-3) based on load order & dependency constraints.""" - tiers = dict.fromkeys(canonical_ids, Tier.TIER_2) - - tier3_set = set(load_last) - for pid in load_last: - tier3_set.update(get_reverse_deps_recursive(pid, rev_deps)) - for pid in tier_config.tier_3: - if pid in canonical_ids: - tier3_set.add(pid) - tier3_set.update(get_reverse_deps_recursive(pid, rev_deps)) - for pid in tier3_set & canonical_ids: - tiers[pid] = Tier.TIER_3 - - tier0_set = set() - for pid in tier_config.tier_0: - if pid in canonical_ids: - tier0_set.add(pid) - tier0_set.update(get_deps_recursive(pid, deps)) - for pid in tier0_set & canonical_ids: - tiers[pid] = Tier.TIER_0 - - tier1_set = set() - for pid in tier_config.tier_1: - if pid in canonical_ids: - tier1_set.add(pid) - tier1_set.update(get_deps_recursive(pid, deps)) - for pid in load_first: - if pid in canonical_ids: - tier1_set.add(pid) - tier1_set.update(get_deps_recursive(pid, deps)) - for pid in tier1_set & canonical_ids: - if tiers[pid] == Tier.TIER_2: - tiers[pid] = Tier.TIER_1 - - return tiers - - -def get_deps_recursive( - start: PackageId, - deps: dict[PackageId, set[PackageId]], - visited: set[PackageId] | None = None, -) -> set[PackageId]: - """Collect all transitive dependencies for a given package.""" - if visited is None: - visited = set() - if start in visited: - return set() - - visited.add(start) - result: set[PackageId] = set() - stack = [start] - while stack: - pid = stack.pop() - for dep in deps.get(pid, set()): - result.add(dep) - if dep not in visited: - visited.add(dep) - stack.append(dep) - return result - - -def get_reverse_deps_recursive( - start: PackageId, - rev_deps: dict[PackageId, set[PackageId]], - visited: set[PackageId] | None = None, -) -> set[PackageId]: - """Collect all transitive reverse-dependents for a given package.""" - if visited is None: - visited = set() - if start in visited: - return set() - - visited.add(start) - result: set[PackageId] = set() - stack = [start] - while stack: - pid = stack.pop() - for rdep in rev_deps.get(pid, set()): - result.add(rdep) - if rdep not in visited: - visited.add(rdep) - stack.append(rdep) - return result - - -def find_cycle( - nodes: set[PackageId], - deps: dict[PackageId, set[PackageId]], -) -> list[PackageId] | None: - """Detect a dependency cycle in the given nodes using iterative DFS.""" - visited: set[PackageId] = set() - rec_stack: set[PackageId] = set() - parent: dict[PackageId, PackageId] = {} - - for root in nodes: - if root in visited: - continue - - parent[root] = root - visited.add(root) - rec_stack.add(root) - stack = [(root, iter(deps.get(root, set())))] - while stack: - pid, dependents = stack[-1] - try: - dep = next(dependents) - except StopIteration: - stack.pop() - rec_stack.remove(pid) - continue - - if dep not in nodes: - continue - if dep not in visited: - parent[dep] = pid - visited.add(dep) - rec_stack.add(dep) - stack.append((dep, iter(deps.get(dep, set())))) - elif dep in rec_stack: - cycle = [dep] - current = pid - while current != dep: - cycle.append(current) - current = parent[current] - cycle.append(dep) - return list(reversed(cycle)) - - return None diff --git a/src/pxmodrim/core/structures.py b/src/pxmodrim/core/structures.py deleted file mode 100644 index e395e1c..0000000 --- a/src/pxmodrim/core/structures.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -import msgspec - - -class CollectionStats(msgspec.Struct): - total: int = 0 - active: int = 0 - inactive: int = 0 - errors: int = 0 diff --git a/src/pxmodrim/core/support/__init__.py b/src/pxmodrim/core/support/__init__.py deleted file mode 100644 index 6b48ea6..0000000 --- a/src/pxmodrim/core/support/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.support.report import ( - PasteUploader, - ReportUploadError, - build_report, - get_app_version, - read_log_tail, - redact, -) - -__all__ = [ - "PasteUploader", - "ReportUploadError", - "build_report", - "get_app_version", - "read_log_tail", - "redact", -] diff --git a/src/pxmodrim/core/support/report.py b/src/pxmodrim/core/support/report.py deleted file mode 100644 index 5ff5062..0000000 --- a/src/pxmodrim/core/support/report.py +++ /dev/null @@ -1,311 +0,0 @@ -from __future__ import annotations - -import asyncio -import getpass -import json -import platform -import re -import urllib.error -import urllib.request -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from pxmodrim.core.config import config_dir - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - -MAX_LOG_BYTES = 2 * 1024 * 1024 -DEFAULT_UPLOAD_ENDPOINT = "https://paste.rs/" - - -def get_app_version() -> str: - """Return the application version from package metadata, with fallback.""" - try: - return version("pxmodrim") - except PackageNotFoundError: - return "unknown" - - -# Username directories under these roots; matched case-insensitively. -# Backslashes may be doubled: repr() of a Windows path in a log line. -_USER_DIR_PREFIX = r"(?:/home|/Users|/run/media|/media|\\{1,2}Users)(?:\\{1,2}|/)" -# Path components start and end where no adjacent name characters exist. -_COMPONENT_START = r"(? str: - """Replace the home directory with ~ and user-directory usernames with .""" - home_str = str(Path.home() if home_dir is None else Path(home_dir)).rstrip("/\\") - - if username is None: - try: - username = getpass.getuser() - except (OSError, KeyError): - username = "" - - if home_str: - backslashed = home_str.replace("/", "\\") - variants = { - home_str, - home_str.replace("\\", "/"), - backslashed, - backslashed.replace("\\", "\\\\"), - } - alternatives = "|".join( - re.escape(v) for v in sorted(variants, key=len, reverse=True) - ) - text = re.sub( - f"{_COMPONENT_START}(?:{alternatives}){_COMPONENT_END}", "~", text - ) - - # Only path contexts: a bare word match would corrupt ids like ludeon.rimworld. - if username: - pattern = re.compile( - f"({_USER_DIR_PREFIX}){re.escape(username)}{_COMPONENT_END}", - re.IGNORECASE, - ) - text = pattern.sub(r"\1", text) - return text - - -def _format_size(size_bytes: int) -> str: - """Format byte count into human-readable B, KiB, or MiB.""" - if size_bytes >= 1024 * 1024: - return f"{size_bytes / (1024 * 1024):.1f} MiB" - if size_bytes >= 1024: - return f"{size_bytes / 1024:.1f} KiB" - return f"{size_bytes} B" - - -def _log_line_text(line: str) -> str: - """Unpack a loguru serialized JSON record to its text; pass other lines through.""" - trimmed = line.strip() - if not (trimmed.startswith("{") and trimmed.endswith("}")): - return line - try: - data = json.loads(trimmed) - except ValueError: - return line - if isinstance(data, dict) and "text" in data: - return str(data["text"]).rstrip("\r\n") - return line - - -def _parse_log_text(raw_text: str) -> str: - """Extract plain text lines from log, unpacking loguru JSON lines if present.""" - result = "\n".join(_log_line_text(line) for line in raw_text.splitlines()) - if raw_text.endswith("\n") and result: - result += "\n" - return result - - -def _after_first_newline(data: bytes) -> bytes: - """Drop the partial leading line of a tail slice.""" - nl = data.find(b"\n") - return data[nl + 1 :] if nl != -1 else data - - -def _read_raw_tail(path: Path, size: int, window: int) -> bytes: - with path.open("rb") as f: - if size <= window: - return f.read() - f.seek(size - window) - return _after_first_newline(f.read(window)) - - -def read_log_tail(log_path: Path | None = None, max_bytes: int = MAX_LOG_BYTES) -> str: - """Read up to max_bytes from the end of the log file, converted to plain text.""" - path = log_path or (config_dir() / "logs" / "pxmodrim.log") - if not path.is_file(): - return "" - - try: - size = path.stat().st_size - except OSError as exc: - return f"" - - if size == 0: - return "" - - # JSON-serialized records shrink when unpacked, so read a wider raw window. - read_window = max(max_bytes * 5, 10 * 1024 * 1024) - try: - raw_bytes = _read_raw_tail(path, size, read_window) - except OSError as exc: - return f"" - - plain_text = _parse_log_text(raw_bytes.decode("utf-8", errors="replace")) - encoded = plain_text.encode("utf-8") - if len(encoded) <= max_bytes: - return plain_text - - tail = _after_first_newline(encoded[-max_bytes:]) - marker = ( - f"[... truncated, showing last {_format_size(len(tail))} " - f"of {_format_size(len(encoded))} ...]\n" - ) - return marker + tail.decode("utf-8", errors="replace") - - -def build_report( - ctx: CoreContext | Any, - log_path: Path | None = None, - home_dir: str | Path | None = None, - username: str | None = None, - max_log_bytes: int = MAX_LOG_BYTES, -) -> str: - """Build a plain-text system diagnostic and log report with redacted user info.""" - lines: list[str] = [] - - # App & runtime diagnostics - app_ver = get_app_version() - py_ver = platform.python_version() - - try: - from PySide6.QtCore import qVersion - - qt_ver = qVersion() - except (ImportError, RuntimeError, AttributeError): - qt_ver = "Unknown" - - try: - pyside_ver = version("PySide6") - except PackageNotFoundError: - try: - import PySide6 - - pyside_ver = getattr(PySide6, "__version__", "Unknown") - except (ImportError, AttributeError): - pyside_ver = "Unknown" - - os_info = f"{platform.system()} {platform.release()}" - arch = platform.machine() - - lines.append("=== PxModRim System Report ===") - lines.append(f"App Version: {app_ver}") - lines.append(f"Python Version: {py_ver}") - lines.append(f"Qt Version: {qt_ver}") - lines.append(f"PySide6 Version: {pyside_ver}") - lines.append(f"OS: {os_info} ({arch})") - lines.append("") - - # Game version and configured paths from CoreContext - game_version = getattr(ctx, "game_version", "Unknown") - cfg = getattr(ctx, "config", None) - paths = getattr(cfg, "paths", None) if cfg is not None else None - - lines.append("=== RimWorld Configuration ===") - lines.append(f"Game Version: {game_version}") - lines.append(f"Game Path: {getattr(paths, 'game', '') or ''}") - lines.append(f"Config Folder: {getattr(paths, 'config_folder', '') or ''}") - lines.append(f"Local Mods Path: {getattr(paths, 'local', '') or ''}") - lines.append(f"Workshop Mods Path: {getattr(paths, 'workshop', '') or ''}") - lines.append("") - - # Mod collection statistics - all_mods = getattr(ctx, "all_mods", {}) - active_uuids = getattr(ctx, "active_uuids", []) - total_mods_count = len(all_mods) if all_mods else 0 - active_mods_count = len(active_uuids) if active_uuids else 0 - - lines.append("=== Mod Collection ===") - lines.append(f"Total Mods: {total_mods_count}") - lines.append(f"Active Mods: {active_mods_count}") - lines.append("") - - # Active mods in load order - lines.append("=== Active Mods (Load Order) ===") - if active_uuids: - for i, uuid in enumerate(active_uuids, 1): - mod = all_mods.get(uuid) if all_mods else None - if mod is not None: - mod_name = getattr(mod, "name", "Unknown Mod Name") - pid = getattr(mod, "package_id", "Unknown") - else: - mod_name = "Unknown Mod" - pid = uuid - lines.append(f"{i:3d}. {mod_name} ({pid})") - else: - lines.append(" ") - lines.append("") - - # Application log tail - lines.append("=== Application Log ===") - lines.append(read_log_tail(log_path, max_bytes=max_log_bytes)) - - full_text = "\n".join(lines) - return redact(full_text, home_dir=home_dir, username=username) - - -class ReportUploadError(Exception): - """Raised when log or diagnostic report upload fails.""" - - -class PasteUploader: - """Uploader for posting plain-text reports to a paste service.""" - - def __init__( - self, endpoint: str = DEFAULT_UPLOAD_ENDPOINT, timeout: float = 30.0 - ) -> None: - self.endpoint = endpoint - self.timeout = timeout - - def upload(self, text: str) -> str: - """Synchronously POST text to the endpoint and return the resulting URL. - - Raises ReportUploadError on non-2xx response, network failure, or non-URL body. - """ - if not text or not text.strip(): - raise ReportUploadError("Report text is empty") - - data = text.encode("utf-8") - app_ver = get_app_version() - headers = { - "User-Agent": f"PxModRim/{app_ver}", - "Content-Type": "text/plain; charset=utf-8", - } - req = urllib.request.Request( - self.endpoint, - data=data, - headers=headers, - method="POST", - ) - - try: - with urllib.request.urlopen(req, timeout=self.timeout) as response: - status = getattr(response, "status", 200) - if not (200 <= status < 300): - raise ReportUploadError(f"Upload failed: HTTP status {status}") - body = response.read().decode("utf-8").strip() - except urllib.error.HTTPError as exc: - raise ReportUploadError( - f"Upload failed: HTTP {exc.code} {exc.reason}" - ) from exc - except urllib.error.URLError as exc: - raise ReportUploadError( - f"Upload failed: network error ({exc.reason})" - ) from exc - except TimeoutError as exc: - raise ReportUploadError( - "Upload failed: request timed out after 30s" - ) from exc - except OSError as exc: - raise ReportUploadError(f"Upload failed: {exc}") from exc - - if not body.startswith(("http://", "https://")): - raise ReportUploadError( - f"Upload failed: server did not return a valid URL: {body[:100]!r}" - ) - - return body - - async def async_upload(self, text: str) -> str: - """Asynchronously upload text using asyncio.to_thread.""" - return await asyncio.to_thread(self.upload, text) diff --git a/src/pxmodrim/core/utils.py b/src/pxmodrim/core/utils.py deleted file mode 100644 index b18f713..0000000 --- a/src/pxmodrim/core/utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - - -def find_about_xml(mod_path: Path) -> Path | None: - """Find About.xml in a mod directory (case-insensitive).""" - if not mod_path.is_dir(): - return None - candidate = mod_path / "About" / "About.xml" - if candidate.is_file(): - return candidate - for entry in mod_path.iterdir(): - if entry.name.lower() == "about" and entry.is_dir(): - for child in entry.iterdir(): - if child.name.lower() == "about.xml" and child.is_file(): - return child - break - return None diff --git a/src/pxmodrim/core/workshop/__init__.py b/src/pxmodrim/core/workshop/__init__.py deleted file mode 100644 index 730c9b3..0000000 --- a/src/pxmodrim/core/workshop/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.workshop.catalog import CatalogSettings, WorkshopCatalog -from pxmodrim.core.workshop.client import CatalogClient, CatalogError -from pxmodrim.core.workshop.types import ( - Author, - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - DownloadPlan, - InstallState, - Preview, - ResolvedDownload, - Votes, -) - -__all__ = [ - "Author", - "CatalogClient", - "CatalogCollection", - "CatalogError", - "CatalogMod", - "CatalogPage", - "CatalogQuery", - "CatalogSettings", - "CollectionDetail", - "Discover", - "DownloadPlan", - "InstallState", - "Preview", - "ResolvedDownload", - "Votes", - "WorkshopCatalog", -] diff --git a/src/pxmodrim/core/workshop/catalog.py b/src/pxmodrim/core/workshop/catalog.py deleted file mode 100644 index d1ca96e..0000000 --- a/src/pxmodrim/core/workshop/catalog.py +++ /dev/null @@ -1,357 +0,0 @@ -from __future__ import annotations - -import asyncio -import hashlib -import time -from collections.abc import AsyncIterator, Awaitable, Callable -from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar - -import msgspec -from loguru import logger - -from pxmodrim.core.downloads.manager import DownloadManager, download_manager -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin -from pxmodrim.core.plugin_config import PluginConfig -from pxmodrim.core.workshop.client import CatalogClient, CatalogError -from pxmodrim.core.workshop.favourites import FavouriteCollections -from pxmodrim.core.workshop.types import ( - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - DownloadPlan, - InstallState, -) - -if TYPE_CHECKING: - from pathlib import Path - - from pxmodrim.core.config import AppConfig - from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads.types import DownloadResult - from pxmodrim.core.models.metadata.structures import ListedMod - -FRESH_SECONDS = 300.0 -_CACHE_LIMIT = 200 -_DISK_LIMIT = 300 - -_V = TypeVar("_V") - - -class _Stored(msgspec.Struct, Generic[_V]): # noqa: UP046 - at: float - value: _V - - -def _cache_file(directory: Path, key: str) -> Path: - return directory / f"{hashlib.sha256(key.encode()).hexdigest()[:32]}.json" - - -def _read_stored(path: Path, value_type: Any) -> tuple[float, Any] | None: - stored_type: Any = _Stored[value_type] - try: - stored = msgspec.json.decode(path.read_bytes(), type=stored_type) - except (OSError, msgspec.DecodeError): - return None - return stored.at, stored.value - - -def _write_stored(directory: Path, path: Path, entry: tuple[float, Any]) -> None: - directory.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(".tmp") - temporary.write_bytes(msgspec.json.encode(_Stored(entry[0], entry[1]))) - temporary.replace(path) - files = sorted(directory.glob("*.json"), key=lambda file: file.stat().st_mtime) - for stale in files[:-_DISK_LIMIT]: - stale.unlink(missing_ok=True) - - -DEFAULT_CATALOG_URL = "https://api.modrim.pyxiion.dev" - - -class CatalogSettings(msgspec.Struct, frozen=True): - url: str = DEFAULT_CATALOG_URL - - -class WorkshopCatalog(Plugin): - name = "workshop_catalog" - dependencies: ClassVar[list[str]] = ["downloads"] - - def __init__( - self, - client_factory: Callable[[str], CatalogClient] = CatalogClient, - ) -> None: - self.installed_changed: Event[None] = Event() - self.queue_changed: Event[None] = Event() - self._queue: list[list[str]] = [] - self._queued: set[str] = set() - self._runner: asyncio.Future[None] | None = None - self._idle = asyncio.Event() - self.catalog_url_changed: Event[str] = Event() - self.settings = PluginConfig(None, self.name, CatalogSettings) - self.settings.changed.connect(self._on_settings_changed) - self.favourites = FavouriteCollections(None) - self._client_factory = client_factory - self._base_url = self.settings.value.url.strip().rstrip("/") - self._client: CatalogClient | None = None - self._clients: list[CatalogClient] = [] - self._ctx: CoreContext | None = None - self._manager: DownloadManager | None = None - self._installed_index: dict[str, list[ListedMod]] | None = None - self._responses: dict[str, tuple[float, Any]] = {} - self._cache_dir: Path | None = None - - def setup(self, ctx: CoreContext) -> None: - self._ctx = ctx - if ctx.has_config_service: - self.settings = PluginConfig( - ctx.config_service, - self.name, - CatalogSettings, - legacy={"workshop_catalog_url": "url"}, - ) - self.settings.changed.connect(self._on_settings_changed) - self._change_url(self.settings.value.url) - self._cache_dir = ctx.config_service.config_dir / "workshop-cache" - self.favourites = FavouriteCollections(ctx.config_service) - self._manager = download_manager(ctx) - ctx.mod_service.mods_changed.connect(self._on_installed_changed) - self._manager.download_finished.connect(self._on_download_finished) - self._manager.busy_changed.connect(self._on_busy_changed) - ctx.config_changed.connect(self._on_config_changed) - - def _on_installed_changed(self, _: None) -> None: - self._installed_index = None - self.installed_changed.emit(None) - - def _on_download_finished(self, _: DownloadResult) -> None: - self._installed_index = None - self.installed_changed.emit(None) - - def _on_config_changed(self, _config: AppConfig) -> None: - self._installed_index = None - self.installed_changed.emit(None) - - def _on_settings_changed(self, settings: CatalogSettings) -> None: - self._change_url(settings.url) - - def _change_url(self, url: str) -> None: - url = url.strip().rstrip("/") - if self._base_url == url: - return - self._base_url = url - self._client = None - self.catalog_url_changed.emit(url) - - def set_base_url(self, url: str) -> None: - self.settings.update(CatalogSettings(url.strip().rstrip("/"))) - - @property - def configured(self) -> bool: - return bool(self._base_url) - - def _catalog_client(self) -> CatalogClient: - if not self.configured: - raise CatalogError("Workshop catalog is disabled.") - if self._client is None: - self._client = self._client_factory(self._base_url) - # Keep replaced clients alive until shutdown so in-flight requests finish. - self._clients.append(self._client) - return self._client - - async def shutdown(self) -> None: - self.cancel_queue() - if self._runner is not None: - self._runner.cancel() - self._runner = None - if self._ctx is not None: - self._ctx.mod_service.mods_changed.disconnect(self._on_installed_changed) - self._ctx.config_changed.disconnect(self._on_config_changed) - if self._manager is not None: - self._manager.download_finished.disconnect(self._on_download_finished) - self._manager.busy_changed.disconnect(self._on_busy_changed) - for client in self._clients: - await client.shutdown() - self._clients.clear() - self._client = None - - async def discover(self) -> Discover: - return await self._catalog_client().discover() - - async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: - return await self._catalog_client().mods(q) - - async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: - return await self._catalog_client().collections(q) - - async def mod(self, id: str) -> CatalogMod | None: - return await self._catalog_client().mod(id) - - async def collection(self, id: str) -> CollectionDetail | None: - return await self._catalog_client().collection(id) - - def _installed(self) -> dict[str, list[ListedMod]]: - if self._ctx is None: - raise RuntimeError("Workshop catalog accessed before setup") - if self._installed_index is None: - installed: dict[str, list[ListedMod]] = {} - for mod in self._ctx.all_mods.values(): - id = mod.published_file_id - if id is not None: - installed.setdefault(id, []).append(mod) - self._installed_index = installed - return self._installed_index - - async def cached[T]( - self, - key: str, - produce: Callable[[], Awaitable[T]], - fresh_for: float = FRESH_SECONDS, - value_type: Any = None, - ) -> AsyncIterator[T]: - """Yield the remembered value at once, then the refreshed one if it differs. - - A value younger than *fresh_for* is served without contacting the catalog. - With *value_type*, values are also kept on disk, so they survive a restart. - """ - key = f"{self._base_url}|{key}" - entry = self._responses.get(key) - directory = self._cache_dir if value_type is not None else None - if entry is None and directory is not None: - entry = await asyncio.to_thread( - _read_stored, _cache_file(directory, key), value_type - ) - if entry is not None: - self._responses[key] = entry - while len(self._responses) > _CACHE_LIMIT: - del self._responses[next(iter(self._responses))] - yield entry[1] - if time.time() - entry[0] < fresh_for: - return - value = await produce() - changed = entry is None or value != entry[1] - if entry is not None and not changed: - value = entry[1] - remembered = (time.time(), value) - self._responses.pop(key, None) - self._responses[key] = remembered - while len(self._responses) > _CACHE_LIMIT: - del self._responses[next(iter(self._responses))] - if directory is not None: - try: - await asyncio.to_thread( - _write_stored, directory, _cache_file(directory, key), remembered - ) - except OSError as exc: - logger.warning("[workshop] cannot write cache: {}", exc) - if changed: - yield value - - def _install_state( - self, mod: CatalogMod, installed: dict[str, list[ListedMod]] - ) -> InstallState: - local = installed.get(mod.id) - if not local: - return "missing" - if self._manager is None: - raise RuntimeError("Workshop catalog accessed before setup") - synced = [ - timestamp - for item in local - if (timestamp := self._manager.last_synced(item)) is not None - ] - if synced and mod.updated_at is not None and max(synced) < mod.updated_at: - return "outdated" - return "installed" - - def install_state(self, mod: CatalogMod) -> InstallState: - return self._install_state(mod, self._installed()) - - async def installed_with_updates(self) -> list[CatalogMod]: - client = self._catalog_client() - items, _ = await client.mods_batch(list(self._installed())) - return items - - async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - resolved = await self._catalog_client().resolve(mod_ids, collection_ids) - installed = self._installed() - to_download: list[str] = [] - already_current: list[str] = [] - for id in resolved.mod_ids: - mod = resolved.items.get(id) - if not isinstance(mod, CatalogMod): - raise CatalogError( - "The Workshop catalog returned an invalid download plan." - ) - if self._install_state(mod, installed) == "installed": - already_current.append(id) - else: - to_download.append(id) - return DownloadPlan( - to_download=to_download, - already_current=already_current, - unavailable_ids=resolved.unavailable_ids, - incomplete_collection_ids=resolved.incomplete_collection_ids, - titles={id: item.title for id, item in resolved.items.items()}, - is_complete=resolved.is_complete, - ) - - async def download(self, plan: DownloadPlan) -> DownloadResult: - if self._manager is None: - raise RuntimeError("Workshop catalog accessed before setup") - return await self._manager.download_mods(plan.to_download) - - @property - def queued_ids(self) -> frozenset[str]: - return frozenset(self._queued) - - def enqueue(self, plan: DownloadPlan) -> None: - """Queue *plan* behind any running download without waiting for it.""" - if self._manager is None: - raise RuntimeError("Workshop catalog accessed before setup") - active = self._manager.active_ids - ids = [ - id for id in plan.to_download if id not in self._queued and id not in active - ] - if not ids: - return - self._queue.append(ids) - self._queued.update(ids) - self.queue_changed.emit(None) - if self._runner is None or self._runner.done(): - self._runner = asyncio.ensure_future(self._drain()) - - def cancel_queue(self) -> None: - self._queue.clear() - self._queued.clear() - self.queue_changed.emit(None) - - async def _drain(self) -> None: - manager = self._manager - if manager is None: - return - while self._queue: - ids = self._queue.pop(0) - waited = manager.is_downloading - while manager.is_downloading: - self._idle.clear() - await self._idle.wait() - if waited and manager.cancelled: - self.cancel_queue() - return - try: - await manager.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[workshop] queued download failed: {}", exc) - self._queued.difference_update(ids) - self.queue_changed.emit(None) - if manager.cancelled: - self.cancel_queue() - return - - def _on_busy_changed(self, busy: bool) -> None: - if not busy: - self._idle.set() diff --git a/src/pxmodrim/core/workshop/client.py b/src/pxmodrim/core/workshop/client.py deleted file mode 100644 index a494aaf..0000000 --- a/src/pxmodrim/core/workshop/client.py +++ /dev/null @@ -1,183 +0,0 @@ -from __future__ import annotations - -from urllib.parse import quote - -import httpx -import msgspec - -from pxmodrim.core.workshop.types import ( - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - ResolvedDownload, -) - - -class CatalogError(Exception): - def __init__(self, message: str, status: int | None = None) -> None: - super().__init__(message) - self.message = message - self.status = status - - -class _ErrorMessage(msgspec.Struct): - message: str - - -class _ErrorEnvelope(msgspec.Struct): - error: _ErrorMessage - - -class _Batch(msgspec.Struct): - items: list[CatalogMod] - unavailable_ids: list[str] - - -class CatalogClient: - def __init__( - self, base_url: str, transport: httpx.AsyncBaseTransport | None = None - ) -> None: - self._base_url = base_url.strip().rstrip("/") - self._http = httpx.AsyncClient(transport=transport, timeout=30.0) - - async def shutdown(self) -> None: - await self._http.aclose() - - async def _request[T]( - self, - method: str, - path: str, - response_type: type[T], - *, - params: dict[str, str | int] | None = None, - payload: dict[str, list[str]] | None = None, - ) -> T: - if not self._base_url: - raise CatalogError("Workshop catalog is disabled.") - try: - response = await self._http.request( - method, - f"{self._base_url}/catalog/{path}", - params=params, - json=payload, - timeout=30.0, - ) - except (httpx.HTTPError, httpx.InvalidURL) as exc: - raise CatalogError(f"Cannot reach the Workshop catalog: {exc}") from exc - if not response.is_success: - try: - error = msgspec.json.decode(response.content, type=_ErrorEnvelope) - message = error.error.message - except msgspec.DecodeError: - message = ( - f"Workshop catalog request failed " - f"(HTTP {response.status_code} {response.reason_phrase})." - ) - raise CatalogError(message, response.status_code) - try: - return msgspec.json.decode(response.content, type=response_type) - except msgspec.DecodeError as exc: - raise CatalogError( - "The Workshop catalog returned an invalid response.", - response.status_code, - ) from exc - - @staticmethod - def _params(query: CatalogQuery) -> dict[str, str | int]: - params: dict[str, str | int] = { - "q": query.query, - "sort": query.sort, - "limit": query.limit, - "source": query.source, - } - for key, value in ( - ("tag", query.tag), - ("version", query.version), - ("cursor", query.cursor), - ): - if value is not None: - params[key] = value - return params - - async def discover(self) -> Discover: - return await self._request("GET", "discover", Discover) - - async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: - return await self._request( - "GET", "mods", CatalogPage[CatalogMod], params=self._params(q) - ) - - async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: - return await self._request( - "GET", "collections", CatalogPage[CatalogCollection], params=self._params(q) - ) - - async def mod(self, id: str) -> CatalogMod | None: - try: - return await self._request("GET", f"mods/{quote(id, safe='')}", CatalogMod) - except CatalogError as exc: - if exc.status == 404: - return None - raise - - async def collection(self, id: str) -> CollectionDetail | None: - source, separator, key = id.partition(":") - if not separator or source not in {"steam", "picked"} or not key: - raise CatalogError("Invalid Workshop collection ID.") - try: - return await self._request( - "GET", f"collections/{source}/{quote(key, safe='')}", CollectionDetail - ) - except CatalogError as exc: - if exc.status == 404: - return None - raise - - async def mods_batch(self, ids: list[str]) -> tuple[list[CatalogMod], list[str]]: - items: list[CatalogMod] = [] - unavailable: list[str] = [] - for start in range(0, len(ids), 100): - batch = await self._request( - "POST", "mods/batch", _Batch, payload={"ids": ids[start : start + 100]} - ) - items.extend(batch.items) - unavailable.extend(batch.unavailable_ids) - return items, unavailable - - async def resolve( - self, ids: list[str], collection_ids: list[str] - ) -> ResolvedDownload: - roots: dict[str, None] = {} - items: dict[str, CatalogMod | CatalogCollection] = {} - mod_ids: dict[str, None] = {} - unavailable: dict[str, None] = {} - incomplete: dict[str, None] = {} - is_complete = True - batches = max((len(ids) + 49) // 50, (len(collection_ids) + 9) // 10, 1) - for batch in range(batches): - resolved = await self._request( - "POST", - "resolve", - ResolvedDownload, - payload={ - "ids": ids[batch * 50 : (batch + 1) * 50], - "collection_ids": collection_ids[batch * 10 : (batch + 1) * 10], - }, - ) - roots.update(dict.fromkeys(resolved.roots)) - items.update(resolved.items) - mod_ids.update(dict.fromkeys(resolved.mod_ids)) - unavailable.update(dict.fromkeys(resolved.unavailable_ids)) - incomplete.update(dict.fromkeys(resolved.incomplete_collection_ids)) - is_complete = is_complete and resolved.is_complete - return ResolvedDownload( - roots=list(roots), - items=items, - mod_ids=list(mod_ids), - unavailable_ids=list(unavailable), - incomplete_collection_ids=list(incomplete), - is_complete=is_complete, - ) diff --git a/src/pxmodrim/core/workshop/favourites.py b/src/pxmodrim/core/workshop/favourites.py deleted file mode 100644 index dff168c..0000000 --- a/src/pxmodrim/core/workshop/favourites.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import msgspec - -from pxmodrim.core.plugin_config import PluginConfig -from pxmodrim.core.workshop.types import CatalogCollection - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - from pxmodrim.core.events import Event - - -class _Stored(msgspec.Struct, frozen=True): - collections: list[CatalogCollection] = msgspec.field(default_factory=list) - - -class FavouriteCollections: - """Collections the user starred, newest first. - - Each entry is the last catalog snapshot of the collection, so the list shows - without a network round trip and survives the catalog dropping it. - """ - - __slots__ = ("_config", "_ids") - - def __init__(self, service: ConfigService | None) -> None: - self._config = PluginConfig(service, "workshop_favourites", _Stored) - self._ids = {item.id for item in self._config.value.collections} - - @property - def changed(self) -> Event[_Stored]: - return self._config.changed - - @property - def items(self) -> list[CatalogCollection]: - return list(self._config.value.collections) - - def __contains__(self, collection_id: str) -> bool: - return collection_id in self._ids - - def set(self, collection: CatalogCollection, favourite: bool) -> None: - if (collection.id in self._ids) == favourite: - return - rest = [c for c in self._config.value.collections if c.id != collection.id] - self._save([collection, *rest] if favourite else rest) - - def remember(self, collection: CatalogCollection) -> None: - """Refresh the stored snapshot of a starred collection.""" - if collection.id not in self._ids: - return - stored = self._config.value.collections - if collection in stored: - return - self._save([collection if c.id == collection.id else c for c in stored]) - - def _save(self, collections: list[CatalogCollection]) -> None: - self._ids = {item.id for item in collections} - self._config.update(_Stored(collections)) diff --git a/src/pxmodrim/core/workshop/types.py b/src/pxmodrim/core/workshop/types.py deleted file mode 100644 index 4522890..0000000 --- a/src/pxmodrim/core/workshop/types.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -from typing import Generic, Literal, TypeVar - -import msgspec - - -class Author(msgspec.Struct, frozen=True): - id: str | None - name: str | None - profile_url: str | None - - -class Preview(msgspec.Struct, frozen=True): - type: Literal["image", "video"] - url: str - - -class Votes(msgspec.Struct, frozen=True): - up: int - down: int - positive_percent: float | None - - -class CatalogMod(msgspec.Struct, frozen=True, tag="mod", tag_field="kind"): - id: str - source: Literal["steam"] - title: str - author: Author - description: str - description_format: Literal["bbcode"] - preview_url: str | None - previews: list[Preview] - workshop_url: str - tags: list[str] - supported_versions: list[str] - created_at: int | None - updated_at: int | None - file_size: str | None - subscriptions: int | None - votes: Votes | None - dependencies: list[str] - package_id: str | None - incompatible: bool - - @property - def kind(self) -> Literal["mod"]: - return "mod" - - -class CatalogCollection( - msgspec.Struct, frozen=True, tag="collection", tag_field="kind" -): - id: str - source: Literal["steam", "picked"] - steam_id: str | None - title: str - author: Author - description: str - description_format: Literal["bbcode", "text"] - preview_url: str | None - previews: list[Preview] - workshop_url: str | None - tags: list[str] - supported_versions: list[str] - created_at: int | None - updated_at: int | None - member_ids: list[str] - member_count: int - member_previews: list[str] = msgspec.field(default_factory=list) - total_size: str | None = None - no_common_version: bool = False - - @property - def kind(self) -> Literal["collection"]: - return "collection" - - -T = TypeVar("T") - - -class CatalogPage(msgspec.Struct, Generic[T], frozen=True): # noqa: UP046 - # msgspec resolves postponed annotations using module globals, not PEP 695 scope. - items: list[T] - total: int - next_cursor: str | None - - -class CollectionDetail(msgspec.Struct, frozen=True): - collection: CatalogCollection - members: list[CatalogMod | CatalogCollection] - unavailable_ids: list[str] - is_complete: bool - - -class Discover(msgspec.Struct, frozen=True): - mods: CatalogPage[CatalogMod] - collections: CatalogPage[CatalogCollection] - - -class ResolvedDownload(msgspec.Struct, frozen=True): - roots: list[str] - items: dict[str, CatalogMod | CatalogCollection] - mod_ids: list[str] - unavailable_ids: list[str] - incomplete_collection_ids: list[str] - is_complete: bool - - -class CatalogQuery(msgspec.Struct, frozen=True): - query: str = "" - tag: str | None = None - version: str | None = None - sort: str = "popular" - limit: int = 24 - cursor: str | None = None - source: str = "all" - - -InstallState = Literal["missing", "installed", "outdated"] - - -class DownloadPlan(msgspec.Struct, frozen=True): - to_download: list[str] - already_current: list[str] - unavailable_ids: list[str] - incomplete_collection_ids: list[str] - titles: dict[str, str] - is_complete: bool diff --git a/src/pxmodrim/core/xml.py b/src/pxmodrim/core/xml.py deleted file mode 100644 index dfffae8..0000000 --- a/src/pxmodrim/core/xml.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import os -from typing import Any - -import lxml.etree as ET -from loguru import logger - - -def _text(elem: ET._Element, tag: str) -> str | None: - """Return stripped text of the first child matching *tag*, or None.""" - found = elem.find(tag) - if found is None or found.text is None: - return None - text = found.text.strip() - return text or None - - -def etree_to_dict(t: Any) -> dict[str, Any]: - """Recursively convert an lxml Element tree into a plain nested dictionary.""" - d: dict[str, Any] = {str(t.tag): {}} - children = list(t) - if children: - dd: dict[str, Any] = {} - for dc in map(etree_to_dict, children): - for k, v in dc.items(): - if k in dd: - if not isinstance(dd[k], list): - dd[k] = [dd[k]] - dd[k].append(v) - else: - dd[k] = v - d[str(t.tag)] = dd - if t.attrib: - d[str(t.tag)].update(("@" + str(k), str(v)) for k, v in t.attrib.items()) - if t.text: - text = str(t.text or "").strip() - if children or t.attrib: - if text: - d[str(t.tag)]["#text"] = text - else: - d[str(t.tag)] = text - return d - - -def xml_path_to_json(path: str) -> dict[str, Any]: - """Parse an XML file at *path* and return its contents as a nested dictionary.""" - if not os.path.exists(path): - logger.error(f"XML file does not exist at: {path}") - return {} - try: - tree = ET.parse(path) - root = tree.getroot() - return etree_to_dict(root) - except (OSError, ET.ParseError) as e: - logger.error(f"Error parsing XML file {path}: {e}") - return {} - - -def dict_to_etree(data: dict[str, Any]) -> ET._Element: - """Convert a dictionary to an lxml.etree Element recursively.""" - if len(data) != 1: - raise ValueError("Dictionary must have exactly one root key") - root_key, root_val = next(iter(data.items())) - root = ET.Element(root_key) - _build_etree(root, root_val) - return root - - -def _build_etree(parent: ET._Element, value: Any) -> None: - """Recursively build XML elements from dict/list/str values.""" - if isinstance(value, dict): - for k, v in value.items(): - if k.startswith("@"): - parent.set(k[1:], str(v)) - elif k == "#text": - parent.text = str(v) - elif isinstance(v, list): - for item in v: - child = ET.SubElement(parent, k) - _build_etree(child, item) - else: - child = ET.SubElement(parent, k) - _build_etree(child, v) - elif isinstance(value, list): - for item in value: - # Anonymous list items - shouldn't happen with well-formed data - child = ET.SubElement(parent, "li") - _build_etree(child, item) - else: - parent.text = str(value) - - -def json_to_xml_write( - data: dict[str, Any], path: str, raise_errs: bool = False -) -> None: - """Write dictionary data to an XML file with pretty printing.""" - try: - root = dict_to_etree(data) - formatted = ET.tostring( - root, encoding="utf-8", xml_declaration=True, pretty_print=True - ) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as f: - f.write(formatted.decode("utf-8")) - logger.debug(f"XML written to {path}") - except Exception as e: - logger.error(f"Error writing XML file: {e}") - if raise_errs: - raise diff --git a/src/pxmodrim/ui/__init__.py b/src/pxmodrim/ui/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/ui/assets/__init__.py b/src/pxmodrim/ui/assets/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/src/pxmodrim/ui/assets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/src/pxmodrim/ui/components/__init__.py b/src/pxmodrim/ui/components/__init__.py deleted file mode 100644 index e6da780..0000000 --- a/src/pxmodrim/ui/components/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from .banner_widget import AspectRatioBanner -from .button import AppButton -from .header_controller import HeaderController -from .header_panel import HeaderPanel -from .icon_button import IconButton -from .icons import icon, pixmap, svg_str -from .procedural_preview import generate_preview -from .progress_dialog import ProgressDialog -from .svg_provider import SvgIconProvider, create_qml_engine -from .toast import Toast, ToastManager -from .view_rail_panel import ViewRailPanel - -__all__ = [ - "AppButton", - "AspectRatioBanner", - "HeaderController", - "HeaderPanel", - "IconButton", - "ProgressDialog", - "SvgIconProvider", - "Toast", - "ToastManager", - "ViewRailPanel", - "create_qml_engine", - "generate_preview", - "icon", - "pixmap", - "svg_str", -] diff --git a/src/pxmodrim/ui/components/banner_widget.py b/src/pxmodrim/ui/components/banner_widget.py deleted file mode 100644 index 45fd463..0000000 --- a/src/pxmodrim/ui/components/banner_widget.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QSize, Qt -from PySide6.QtGui import ( - QColor, - QLinearGradient, - QPainter, - QPaintEvent, - QPixmap, - QResizeEvent, -) -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.theme.constants import ( - BANNER_ASPECT_H, - BANNER_ASPECT_W, - BANNER_MAX_HEIGHT, - BANNER_OVERLAY_HEIGHT, -) -from pxmodrim.ui.theme.palette import PANEL_BG_Q, TEXT_MAIN_Q, TEXT_MUTED_Q - - -class AspectRatioBanner(QWidget): - """Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it.""" - - def __init__( - self, parent: QWidget | None = None, max_height: int = BANNER_MAX_HEIGHT - ) -> None: - super().__init__(parent) - self._pixmap: QPixmap | None = None - self._scaled_pixmap: QPixmap | None = None - self._title = "" - self._subtitle = "" - self._max_height = max_height - self._overlay_height = BANNER_OVERLAY_HEIGHT - self._show_overlay = True - self.setSizePolicy( - self.sizePolicy().Policy.Expanding, - self.sizePolicy().Policy.Fixed, - ) - - def setPixmap(self, pixmap: QPixmap) -> None: - self._pixmap = pixmap - self._recompute_scaled_pixmap() - self.updateGeometry() - self.update() - - def _recompute_scaled_pixmap(self) -> None: - if not self._pixmap or self._pixmap.isNull() or self.width() <= 0: - self._scaled_pixmap = None - return - - self._scaled_pixmap = self._pixmap.scaled( - self.width(), - self.heightForWidth(self.width()), - Qt.AspectRatioMode.KeepAspectRatioByExpanding, - Qt.TransformationMode.SmoothTransformation, - ) - - def setTitle(self, title: str) -> None: - self._title = title - self.update() - - def setSubtitle(self, subtitle: str) -> None: - self._subtitle = subtitle or "" - self.update() - - def setShowOverlay(self, show: bool) -> None: - self._show_overlay = show - self.update() - - def hasHeightForWidth(self) -> bool: - return True - - def heightForWidth(self, width: int) -> int: - return min(width * BANNER_ASPECT_H // BANNER_ASPECT_W, self._max_height) - - def sizeHint(self) -> QSize: - width = self.width() or 300 - return QSize(width, self.heightForWidth(width)) - - def paintEvent(self, event: QPaintEvent) -> None: - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - painter.setClipRect(self.rect()) - - # Background fill when no pixmap - if not self._scaled_pixmap or self._scaled_pixmap.isNull(): - painter.fillRect(self.rect(), PANEL_BG_Q) - else: - x = (self.width() - self._scaled_pixmap.width()) // 2 - y = (self.height() - self._scaled_pixmap.height()) // 2 - painter.drawPixmap(x, y, self._scaled_pixmap) - - # Gradient overlay at bottom (only when no actual preview image) - if self._title and self._show_overlay: - gradient = QLinearGradient( - 0, self.height() - self._overlay_height, 0, self.height() - ) - gradient.setColorAt(0.0, QColor(0, 0, 0, 0)) - gradient.setColorAt(1.0, QColor(0, 0, 0, 180)) - - painter.fillRect( - 0, - self.height() - self._overlay_height, - self.width(), - self._overlay_height, - gradient, - ) - - pad = 16 - text_area_top = self.height() - self._overlay_height + 12 - text_area_w = self.width() - pad * 2 - - font = painter.font() - font.setPointSize(18) - font.setBold(True) - painter.setFont(font) - painter.setPen(TEXT_MAIN_Q) - - title_h = self._overlay_height - 24 - if self._subtitle: - title_h = title_h * 3 // 5 - painter.drawText( - pad, - text_area_top, - text_area_w, - title_h, - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom, - self._title, - ) - - if self._subtitle: - font.setPointSize(12) - font.setBold(False) - painter.setFont(font) - painter.setPen(TEXT_MUTED_Q) - subtitle_top = text_area_top + title_h + 2 - subtitle_h = self._overlay_height - title_h - 26 - painter.drawText( - pad, - subtitle_top, - text_area_w, - subtitle_h, - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop, - self._subtitle, - ) - - def resizeEvent(self, event: QResizeEvent) -> None: - self._recompute_scaled_pixmap() - super().resizeEvent(event) diff --git a/src/pxmodrim/ui/components/button.py b/src/pxmodrim/ui/components/button.py deleted file mode 100644 index ec29ca1..0000000 --- a/src/pxmodrim/ui/components/button.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QPushButton, QWidget - - -class AppButton(QPushButton): - def __init__(self, text: str = "", parent: QWidget | None = None) -> None: - super().__init__(text, parent) - self.setObjectName("appBtn") - self.setCursor(Qt.CursorShape.PointingHandCursor) diff --git a/src/pxmodrim/ui/components/dialog_chrome.py b/src/pxmodrim/ui/components/dialog_chrome.py deleted file mode 100644 index d7213bb..0000000 --- a/src/pxmodrim/ui/components/dialog_chrome.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QEvent, QObject, Qt -from PySide6.QtGui import QEnterEvent, QMouseEvent -from PySide6.QtWidgets import ( - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QSizeGrip, - QWidget, -) - -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - -_TITLE_BAR_HEIGHT = 36 -_BORDER = 1 -_GRIP_SIZE = 16 -_CLOSE_SIZE = 26 - - -class _CloseButton(QPushButton): - """Soft-red close button that fills solid on hover, like the main window's.""" - - def __init__(self, parent: QWidget) -> None: - super().__init__(parent) - self.setObjectName("dialogCloseBtn") - self.setFixedSize(_CLOSE_SIZE, _CLOSE_SIZE) - # A global QPushButton min-height would otherwise beat setFixedSize. - inner = _CLOSE_SIZE - 2 # the 1px QSS border sits outside the content box - self.setStyleSheet( - f"min-width: {inner}px; max-width: {inner}px;" - f" min-height: {inner}px; max-height: {inner}px;" - ) - self.setToolTip("Close") - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self._normal_icon = icon("close", 12, PALETTE["DANGER"]) - self._hover_icon = icon("close", 12, PALETTE["TEXT_ON_ACCENT"]) - self.setIcon(self._normal_icon) - - def enterEvent(self, event: QEnterEvent) -> None: - self.setIcon(self._hover_icon) - super().enterEvent(event) - - def leaveEvent(self, event: QEvent) -> None: - self.setIcon(self._normal_icon) - super().leaveEvent(event) - - -def _close_like_escape(dialog: QDialog) -> None: - if isinstance(dialog, QMessageBox): - button = dialog.escapeButton() - if button is not None: - button.click() - return - if dialog.button(QMessageBox.StandardButton.Cancel) is not None: - dialog.done(int(QMessageBox.StandardButton.Cancel)) - return - dialog.reject() - - -class _TitleBar(QWidget): - def __init__(self, dialog: QDialog, closable: bool) -> None: - super().__init__(dialog) - self.setObjectName("dialogTitleBar") - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - layout = QHBoxLayout(self) - layout.setContentsMargins(14, 0, 6, 0) - layout.setSpacing(6) - - self._title = QLabel(dialog.windowTitle(), self) - self._title.setObjectName("dialogTitleText") - layout.addWidget(self._title, 1) - dialog.windowTitleChanged.connect(self._title.setText) - - if closable: - close_btn = _CloseButton(self) - close_btn.clicked.connect(lambda: _close_like_escape(dialog)) - layout.addWidget(close_btn) - - def mousePressEvent(self, event: QMouseEvent) -> None: - if event.button() == Qt.MouseButton.LeftButton: - handle = self.window().windowHandle() - if handle is not None: - handle.startSystemMove() - event.accept() - return - super().mousePressEvent(event) - - -class _Chrome(QObject): - """Keeps the overlay title bar and size grip pinned to the dialog frame.""" - - def __init__(self, dialog: QDialog, closable: bool, resizable: bool) -> None: - super().__init__(dialog) - self._dialog = dialog - self._bar = _TitleBar(dialog, closable) - self._grip = QSizeGrip(dialog) if resizable else None - dialog.installEventFilter(self) - self._place() - - def _place(self) -> None: - width = self._dialog.width() - self._bar.setGeometry(_BORDER, _BORDER, width - 2 * _BORDER, _TITLE_BAR_HEIGHT) - self._bar.raise_() - if self._grip is not None: - self._grip.setGeometry( - width - _GRIP_SIZE - _BORDER, - self._dialog.height() - _GRIP_SIZE - _BORDER, - _GRIP_SIZE, - _GRIP_SIZE, - ) - self._grip.raise_() - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if watched is self._dialog and event.type() in ( - QEvent.Type.Resize, - QEvent.Type.Show, - ): - self._place() - return False - - -def install_dialog_chrome( - dialog: QDialog, *, closable: bool = True, resizable: bool | None = None -) -> None: - """Give *dialog* the frameless title-bar look of the main window. - - Call after the dialog's layout is built and before it is shown. Native file - pickers keep the OS look. The title bar is an overlay, so the dialog's own - layout only needs extra top margin, which is added here. - """ - if isinstance(dialog, QFileDialog) or dialog.property("chromed"): - return - if resizable is None: - resizable = not (isinstance(dialog, QMessageBox) or dialog.isSizeGripEnabled()) - - keep = dialog.windowFlags() & Qt.WindowType.WindowStaysOnTopHint - dialog.setWindowFlags( - Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | keep - ) - dialog.setProperty("chromed", True) - - layout = dialog.layout() - if layout is not None: - m = layout.contentsMargins() - layout.setContentsMargins( - m.left() + _BORDER, - m.top() + _TITLE_BAR_HEIGHT + _BORDER, - m.right() + _BORDER, - m.bottom() + _BORDER, - ) - _Chrome(dialog, closable, resizable) diff --git a/src/pxmodrim/ui/components/dialogs.py b/src/pxmodrim/ui/components/dialogs.py deleted file mode 100644 index cd4954e..0000000 --- a/src/pxmodrim/ui/components/dialogs.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -from PySide6.QtWidgets import QDialog - -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome - - -async def await_dialog[T: QDialog]( - cls: type[T], - *args: Any, - **kwargs: Any, -) -> tuple[int, T]: - """Show a modal QDialog async and await its result via an asyncio future. - - The dialog is scheduled for deletion on the next loop iteration, so callers - must read what they need from it synchronously after the await. - """ - dialog = cls(*args, **kwargs) - install_dialog_chrome(dialog) - dialog.setModal(True) - - future: asyncio.Future[int] = asyncio.get_running_loop().create_future() - - def handle_finished(result_code: int) -> None: - if not future.done(): - future.set_result(result_code) - - dialog.finished.connect(handle_finished) - dialog.show() - - result_code = await future - asyncio.get_running_loop().call_soon(dialog.deleteLater) - return result_code, dialog diff --git a/src/pxmodrim/ui/components/filter_sidebar.py b/src/pxmodrim/ui/components/filter_sidebar.py deleted file mode 100644 index 45dc80d..0000000 --- a/src/pxmodrim/ui/components/filter_sidebar.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any, cast - -from PySide6.QtCore import QAbstractItemModel, QObject, QUrl, Signal -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.theme.palette import PALETTE - -_QML = Path(__file__).parent / "FilterSidebar.qml" - - -class FilterSidebar(QQuickWidget): - """Left-hand filter list shared by the Mods and Organizer views.""" - - activated = Signal(int) - - def __init__( - self, - qml_engine: QQmlEngine | None, - model: QAbstractItemModel, - parent: QWidget | None = None, - ) -> None: - super().__init__(qml_engine, parent) # pyright: ignore[reportCallIssue, reportArgumentType] - self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self.rootContext().setContextProperty("filterSidebarModel", model) - self.setSource(QUrl.fromLocalFile(str(_QML))) - root = self.rootObject() - if root is not None: - root.setProperty("model", model) - cast(Any, root).activated.connect(self.activated) - - def current_index(self) -> int: - root = self.rootObject() - if root is None: - return -1 - view = root.findChild(QObject, "listView") - return int(view.property("currentIndex")) if view is not None else -1 diff --git a/src/pxmodrim/ui/components/header_controller.py b/src/pxmodrim/ui/components/header_controller.py deleted file mode 100644 index e289c72..0000000 --- a/src/pxmodrim/ui/components/header_controller.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Property, QObject, Signal, Slot - -from pxmodrim.core.constants import LaunchStrategy - -_STRATEGY_LABELS = { - LaunchStrategy.DIRECT: "Direct executable", - LaunchStrategy.STEAM: "Steam", -} - - -class HeaderController(QObject): - refresh_requested = Signal() - sort_requested = Signal() - save_requested = Signal() - launch_requested = Signal() - strategy_changed = Signal(int) - minimize_requested = Signal() - maximize_requested = Signal() - close_requested = Signal() - drag_started = Signal() - unsaved_changes_changed = Signal() - update_mods_requested = Signal() - downloads_busy_changed = Signal() - downloads_progress_changed = Signal() - downloads_progress_shown_changed = Signal() - downloads_requested = Signal() - task_text_changed = Signal() - launch_state_changed = Signal() - - def __init__( - self, - is_frameless: bool = False, - parent: QObject | None = None, - initial_strategy: int = 0, - app_version: str = "", - tooltips: dict[str, str] | None = None, - downloads_available: bool = False, - ) -> None: - super().__init__(parent) - self._is_frameless = is_frameless - self._maximized = False - self._strategy_index: int = initial_strategy - self._unsaved_changes = False - self._launch_state = "idle" - self._app_version = app_version - self._tooltips = tooltips or {} - self._downloads_available = downloads_available - self._downloads_busy = False - self._task_text = "" - self._downloads_text = "" - self._downloads_value = 0 - self._downloads_total = 0 - self._downloads_progress_shown = True - - def is_frameless_getter(self) -> bool: - return self._is_frameless - - is_frameless = Property(bool, is_frameless_getter, constant=True) - - appVersion = Property(str, lambda self: self._app_version, constant=True) - tooltips = Property(dict, lambda self: self._tooltips, constant=True) - downloadsAvailable = Property( - bool, lambda self: self._downloads_available, constant=True - ) - - @Property("QVariantList", constant=True) # type: ignore[operator] - def strategies(self) -> list[dict[str, object]]: - return [{"index": int(s), "label": _STRATEGY_LABELS[s]} for s in LaunchStrategy] - - def set_maximized(self, value: bool) -> None: - if self._maximized != value: - self._maximized = value - self.maximized_changed.emit() - - maximized_changed = Signal() - maximized = Property( - bool, lambda self: self._maximized, set_maximized, notify=maximized_changed - ) - - @Slot() - def refresh(self) -> None: - self.refresh_requested.emit() - - @Slot() - def autoSort(self) -> None: - self.sort_requested.emit() - - @Slot() - def openDownloads(self) -> None: - self.downloads_requested.emit() - - @Slot() - def updateMods(self) -> None: - self.update_mods_requested.emit() - - @Slot() - def save(self) -> None: - self.save_requested.emit() - - @Slot() - def launch(self) -> None: - self.launch_requested.emit() - - @Slot() - def minimize(self) -> None: - self.minimize_requested.emit() - - @Slot() - def maximize(self) -> None: - self.maximize_requested.emit() - - @Slot() - def closeWindow(self) -> None: - self.close_requested.emit() - - @Slot(int) - def setStrategy(self, index: int) -> None: - if self._strategy_index != index: - self._strategy_index = index - self.strategy_changed.emit(index) - - @Slot() - def dragStarted(self) -> None: - self.drag_started.emit() - - # ── QML property ──────────────────────────────────────── - - def _get_strategy_index(self) -> int: - return self._strategy_index - - strategyIndex = Property(int, _get_strategy_index, notify=strategy_changed) - - def _get_unsaved_changes(self) -> bool: - return self._unsaved_changes - - def set_launch_state(self, value: str) -> None: - if self._launch_state != value: - self._launch_state = value - self.launch_state_changed.emit() - - launchState = Property( - str, - lambda self: self._launch_state, - set_launch_state, - notify=launch_state_changed, - ) - - @Slot(bool) - def set_unsaved_changes(self, value: bool) -> None: - if self._unsaved_changes != value: - self._unsaved_changes = value - self.unsaved_changes_changed.emit() - - unsavedChanges = Property( - bool, - _get_unsaved_changes, - set_unsaved_changes, - notify=unsaved_changes_changed, - ) - - @Slot(bool) - def set_task_text(self, text: str) -> None: - if self._task_text != text: - self._task_text = text - self.task_text_changed.emit() - - taskText = Property( - str, - lambda self: self._task_text, - notify=task_text_changed, # type: ignore[arg-type] - ) - - def set_downloads_busy(self, value: bool) -> None: - if self._downloads_busy != value: - self._downloads_busy = value - self.downloads_busy_changed.emit() - - def set_downloads_progress_shown(self, shown: bool) -> None: - if self._downloads_progress_shown != shown: - self._downloads_progress_shown = shown - self.downloads_progress_shown_changed.emit() - - downloadsProgressShown = Property( - bool, - lambda self: self._downloads_progress_shown, - notify=downloads_progress_shown_changed, # type: ignore[arg-type] - ) - - def set_downloads_progress(self, text: str, value: int, total: int) -> None: - self._downloads_text = text - self._downloads_value = value - self._downloads_total = total - self.downloads_progress_changed.emit() - - downloadsText = Property( - str, - lambda self: self._downloads_text, - notify=downloads_progress_changed, # type: ignore[arg-type] - ) - downloadsValue = Property( - int, - lambda self: self._downloads_value, - notify=downloads_progress_changed, # type: ignore[arg-type] - ) - downloadsTotal = Property( - int, - lambda self: self._downloads_total, - notify=downloads_progress_changed, # type: ignore[arg-type] - ) - - downloadsBusy = Property( - bool, - lambda self: self._downloads_busy, - set_downloads_busy, - notify=downloads_busy_changed, - ) diff --git a/src/pxmodrim/ui/components/header_panel.py b/src/pxmodrim/ui/components/header_panel.py deleted file mode 100644 index 8a4b627..0000000 --- a/src/pxmodrim/ui/components/header_panel.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.components.header_controller import HeaderController -from pxmodrim.ui.theme.palette import PALETTE - -_QML_DIR = Path(__file__).parent -_HEADER_QML = _QML_DIR / "Header.qml" - - -class HeaderPanel(QQuickWidget): - def __init__( - self, - controller: HeaderController, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(qml_engine, parent) # type: ignore[arg-type] - self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self.setClearColor(QColor(PALETTE["ELEVATE_1"])) - self.rootContext().setContextProperty("headerController", controller) - self.setSource(QUrl.fromLocalFile(str(_HEADER_QML))) - root_obj = self.rootObject() - if root_obj is None: - raise RuntimeError("Header.qml failed to load: rootObject is None") diff --git a/src/pxmodrim/ui/components/icon_button.py b/src/pxmodrim/ui/components/icon_button.py deleted file mode 100644 index d551c03..0000000 --- a/src/pxmodrim/ui/components/icon_button.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QPushButton, QWidget - -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - - -class IconButton(QPushButton): - def __init__( - self, - icon_name: str, - tooltip: str = "", - primary: bool = False, - size: int = 32, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._icon_name = icon_name - self._primary = primary - self._icon_px = max(16, size // 2) - - self.set_icon_color(PALETTE["TEXT_MUTED"]) - self.setFixedSize(size, size) - self.setObjectName("iconBtn") - # QSS min-height on QPushButton overrides setFixedSize; the 1px border sits - # outside the QSS content box, so subtract it. - inner = size - 2 - self.setStyleSheet( - f"QPushButton#iconBtn {{ min-width: {inner}px; max-width: {inner}px;" - f" min-height: {inner}px; max-height: {inner}px; }}" - ) - if primary: - self.setProperty("primary", True) - self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setToolTip(tooltip) - - def set_icon_color(self, color: str) -> None: - self.setIcon(icon(self._icon_name, self._icon_px, color)) diff --git a/src/pxmodrim/ui/components/icons.py b/src/pxmodrim/ui/components/icons.py deleted file mode 100644 index 39b69ca..0000000 --- a/src/pxmodrim/ui/components/icons.py +++ /dev/null @@ -1,440 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal - -if TYPE_CHECKING: - IconName = Literal[ - "logo", - "download", - "refresh", - "sort", - "save", - "settings", - "search", - "check", - "warning", - "error", - "close", - "minimize", - "maximize", - "restore", - "chevron", - "chevron-left", - "chevron-down", - "folder", - "thumbs-up", - "tag", - "steam", - "local", - "git", - "grid", - "home", - "mods", - "check-circle", - "ban", - "info", - "help", - "empty", - "play", - "link", - "clock", - "trash", - "donut", - "bars", - "grip", - "star", - "star-filled", - ] - -from PySide6.QtCore import QRectF -from PySide6.QtGui import QColor, QGuiApplication, QIcon, QPainter, QPixmap -from PySide6.QtSvg import QSvgRenderer -from PySide6.QtXml import QDomDocument - -from pxmodrim.ui.theme.palette import PALETTE - -_STAR = ( - "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25" - "L7 14.14 2 9.27l6.91-1.01L12 2z" -) - -# Each icon is an SVG path data string. -# stroke svg uses 24x24 viewBox, stroke-width 2, stroke="currentColor" -_ICONS: dict[str, str] = { - "thumbs-up": ( - '' - '' - '' - "" - ), - "logo": ( - '' - '' - '' - "" - ), - "download": ( - '' - '' - '' - '' - "" - ), - "refresh": ( - '' - '' - '' - '' - "" - ), - "sort": ( - '' - '' - '' - '' - "" - ), - "save": ( - '' - '' - '' - '' - "" - ), - "settings": ( - '' - '' - '' - "" - ), - "search": ( - '' - '' - '' - "" - ), - "check": ( - '' - '' - "" - ), - "warning": ( - '' - '' - '' - '' - "" - ), - "error": ( - '' - '' - '' - '' - "" - ), - "close": ( - '' - '' - '' - "" - ), - "minimize": ( - '' - '' - "" - ), - "maximize": ( - '' - '' - "" - ), - "restore": ( - '' - '' - '' - "" - ), - "chevron": ( - '' - '' - "" - ), - "chevron-left": ( - '' - '' - "" - ), - "folder": ( - '' - '' - "" - ), - "tag": ( - '' - '' - '' - "" - ), - "steam": ( - '' - '' - "" - ), - "local": ( - '' - '' - '' - "" - ), - "git": ( - '' - '' - "" - ), - # ── Sidebar icons ── - "grid": ( - '' - '' - '' - '' - '' - "" - ), - "home": ( - '' - '' - '' - "" - ), - "mods": ( - '' - '' - '' - '' - '' - '' - '' - "" - ), - "check-circle": ( - '' - '' - '' - "" - ), - "ban": ( - '' - '' - '' - "" - ), - "help": ( - '' - '' - '' - '' - "" - ), - "info": ( - '' - '' - '' - '' - "" - ), - "play": ( - '' - '' - "" - ), - "empty": ( - '' - '' - '' - "" - ), - "link": ( - '' - '' - '' - "" - ), - "copy": ( - '' - '' - '' - "" - ), - "chevron-down": ( - '' - '' - "" - ), - "clock": ( - '' - '' - '' - "" - ), - "trash": ( - '' - '' - '' - '' - '' - "" - ), - "donut": ( - '' - '' - '' - "" - ), - "bars": ( - '' - '' - '' - '' - "" - ), - "grip": ( - '' - '' - '' - '' - "" - ), - "star": ( - '' - f'' - "" - ), - "star-filled": ( - '' - f'' - "" - ), -} - - -def _color_to_hex(color: str | QColor) -> str: - if isinstance(color, QColor): - return color.name() - return color - - -def svg_str(name: str, color: str = "currentColor") -> str: - raw = _ICONS[name] - if 'xmlns="http://www.w3.org/2000/svg"' not in raw: - raw = raw.replace(" QPixmap: - hex_color = _color_to_hex(color) - svg = svg_str(name, hex_color) - # Rasterise at >=2x so small icons stay crisp even on 1x screens. - screen = QGuiApplication.primaryScreen() - scale = max(2.0, screen.devicePixelRatio() if screen else 1.0) - pm = QPixmap(round(size * scale), round(size * scale)) - pm.setDevicePixelRatio(scale) - pm.fill(QColor(0, 0, 0, 0)) - painter = QPainter(pm) - try: - painter.setRenderHints( - QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform - ) - svg_bytes = svg.encode("utf-8") - doc = QDomDocument() - if doc.setContent(svg_bytes): - renderer = QSvgRenderer(doc.toByteArray()) - renderer.render(painter, QRectF(0, 0, size, size)) - finally: - painter.end() - return pm - - -def icon( - name: str, - size: int = 16, - color: str | QColor = PALETTE["TEXT_MUTED"], -) -> QIcon: - pm = pixmap(name, size, color) - return QIcon(pm) diff --git a/src/pxmodrim/ui/components/mod_activation.py b/src/pxmodrim/ui/components/mod_activation.py deleted file mode 100644 index faf8792..0000000 --- a/src/pxmodrim/ui/components/mod_activation.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterable -from typing import TYPE_CHECKING - -from PySide6.QtWidgets import QMessageBox, QWidget - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.ui.components.dialogs import await_dialog - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - - -def _dependent_detail(mod: ListedMod) -> str: - if isinstance(mod, AboutXmlMod): - return f"{mod.name} ({mod.package_id})" - return mod.name - - -class DependentModsDialog(QMessageBox): - def __init__(self, dependents: list[str], parent: QWidget) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Warning) - self.setWindowTitle("Disable Dependent Mods") - self.setText( - "These active mods depend on the mod you are disabling:\n\n" - + "\n".join(f"• {dependent}" for dependent in dependents) - ) - self.setInformativeText( - "Choose whether to disable only the selected mod or these dependents too." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes - | QMessageBox.StandardButton.No - | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.No) - self.setButtonText( - QMessageBox.StandardButton.Yes, "Disable selected + dependents" - ) - self.setButtonText(QMessageBox.StandardButton.No, "Disable selected only") - yes_button = self.button(QMessageBox.StandardButton.Yes) - if yes_button is not None: - yes_button.setObjectName("primaryAction") - - -async def apply_activation( - ctx: CoreContext, - parent: QWidget, - enable: Iterable[str] = (), - disable: Iterable[str] = (), -) -> bool: - """Enable/disable mods in one core call, confirming active dependents first. - - Every UI surface that toggles mods goes through here so the dependents - prompt cannot diverge between views. Returns whether active state changed. - """ - enabling = list(enable) - disabling = list(disable) - activation = ctx.activation - dependents = activation.dependents_of(disabling, enable=enabling) - if dependents: - all_mods = ctx.all_mods - details = [_dependent_detail(all_mods[uuid]) for uuid in dependents] - result, _ = await await_dialog(DependentModsDialog, details, parent) - if result == QMessageBox.StandardButton.Cancel: - return False - if result == QMessageBox.StandardButton.Yes: - disabling.extend(dependents) - return activation.apply(enable=enabling, disable=disabling) - - -async def toggle_mods(ctx: CoreContext, parent: QWidget, uuids: Iterable[str]) -> bool: - """Flip each mod: active ones are disabled, inactive ones enabled.""" - active = set(ctx.active_uuids) - selected = list(uuids) - return await apply_activation( - ctx, - parent, - enable=[uuid for uuid in selected if uuid not in active], - disable=[uuid for uuid in selected if uuid in active], - ) diff --git a/src/pxmodrim/ui/components/mod_updates.py b/src/pxmodrim/ui/components/mod_updates.py deleted file mode 100644 index c0b23e2..0000000 --- a/src/pxmodrim/ui/components/mod_updates.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Iterable -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from loguru import logger -from PySide6.QtGui import QAction -from PySide6.QtWidgets import QDialog, QMenu, QMessageBox, QWidget - -from pxmodrim.core.downloads import download_manager -from pxmodrim.ui.components.dialogs import await_dialog - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - - -def updatable_ids(ctx: CoreContext, uuids: Iterable[str]) -> list[str]: - """Source ids among the mods *uuids* that PxModRim can update.""" - all_mods = ctx.all_mods - return download_manager(ctx).updatable_ids( - all_mods[u] for u in uuids if u in all_mods - ) - - -CONFIRM_THRESHOLD = 25 - - -@dataclass(frozen=True, slots=True) -class UpdateState: - label: str - enabled: bool - tooltip: str - - -def update_state(ctx: CoreContext, uuids: Iterable[str]) -> UpdateState: - selected = list(uuids) - ids = updatable_ids(ctx, selected) - label = "Update" - if len(selected) > 1: - label += f" ({len(ids)} of {len(selected)})" - downloads = download_manager(ctx) - if not downloads.available: - return UpdateState(label, False, "No download sources are enabled") - if downloads.is_downloading: - return UpdateState(label, False, "A download is in progress") - if not ids: - return UpdateState( - label, False, "None of these mods were downloaded by PxModRim" - ) - return UpdateState(label, True, "") - - -def add_update_action( - menu: QMenu, ctx: CoreContext, uuids: Iterable[str], handler: Callable[[], object] -) -> QAction | None: - """Add an Update entry to *menu*; skipped when no download source exists.""" - if not download_manager(ctx).available: - return None - state = update_state(ctx, uuids) - action = menu.addAction(state.label, handler) - action.setEnabled(state.enabled) - action.setToolTip(state.tooltip) - return action - - -class ConfirmUpdateDialog(QMessageBox): - def __init__(self, count: int, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Question) - QDialog.setWindowTitle(self, "Update Mods") - self.setText(f"Update {count} mods?") - self.setInformativeText( - "Every mod downloaded by PxModRim is checked against its source and " - "changed files are downloaded. Mods managed by other tools, such as " - "Steam's own Workshop folder, are not included. This can take a " - "long time." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - yes = self.button(QMessageBox.StandardButton.Yes) - if yes: - yes.setText("Update") - yes.setObjectName("primaryAction") - - -async def confirm_update(count: int, parent: QWidget) -> bool: - """Ask before a large update; small ones go through unasked.""" - if count < CONFIRM_THRESHOLD: - return True - confirmed, _ = await await_dialog(ConfirmUpdateDialog, count, parent) - return confirmed == QMessageBox.StandardButton.Yes - - -async def update_mods(ctx: CoreContext, uuids: Iterable[str], parent: QWidget) -> None: - """Update the updatable mods among *uuids*; results are toasted by MainWindow.""" - downloads = download_manager(ctx) - ids = updatable_ids(ctx, uuids) - if not ids: - logger.debug("[downloads] update requested but nothing is updatable") - return - if not await confirm_update(len(ids), parent): - return - logger.info("[downloads] update requested for {} mods", len(ids)) - try: - await downloads.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[downloads] update not started: {}", exc) diff --git a/src/pxmodrim/ui/components/popup_parent.py b/src/pxmodrim/ui/components/popup_parent.py deleted file mode 100644 index 065e2a8..0000000 --- a/src/pxmodrim/ui/components/popup_parent.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QEvent, QObject -from PySide6.QtQuickWidgets import QQuickWidget - - -class QuickPopupParenting(QObject): - """Gives every QQuickWidget's offscreen window its top-level window as transient - parent when shown. Without it Wayland refuses ``Popup.Window`` popups (menus, - combo box lists, tooltips): "Failed to create popup … transientParent". - """ - - def eventFilter(self, watched: QObject, event: QEvent) -> bool: - if event.type() == QEvent.Type.Show and isinstance(watched, QQuickWidget): - top = watched.window().windowHandle() - if top is not None: - watched.quickWindow().setTransientParent(top) - return False diff --git a/src/pxmodrim/ui/components/procedural_preview.py b/src/pxmodrim/ui/components/procedural_preview.py deleted file mode 100644 index 81be3b5..0000000 --- a/src/pxmodrim/ui/components/procedural_preview.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import hashlib -import re -from functools import lru_cache - -from PySide6.QtCore import QPointF, QRectF, Qt -from PySide6.QtGui import QColor, QImage, QPainter, QRadialGradient - -from pxmodrim.ui.theme.constants import BANNER_OVERLAY_HEIGHT - - -def _seedrand(seed: str) -> float: - h = int(hashlib.sha256(seed.encode()).hexdigest(), 16) - return (h % 10000) / 10000 - - -def _hsl(h: float, s: float, lum: float) -> QColor: - return QColor.fromHslF(h % 1.0, max(0.0, min(1.0, s)), max(0.0, min(1.0, lum))) - - -_TAG_RE = re.compile(r"[\[(][^\])]*[\])]") -_WORD_RE = re.compile(r"[^\W_]+") -_CAMEL_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[^\W\d_a-zA-Z]+") -_STOP_WORDS = frozenset({"a", "an", "and", "for", "of", "the", "to", "in", "on"}) - - -def initials(title: str) -> str: - """Two-letter monogram: skips [TAGS] and punctuation, splits CamelCase.""" - stripped = _TAG_RE.sub(" ", title) - # A title that is only tags ("[AV]") falls back to its contents. - source = stripped if _WORD_RE.search(stripped) else title - words: list[str] = [] - for chunk in _WORD_RE.findall(source): - words.extend(_CAMEL_RE.findall(chunk) or [chunk]) - meaningful = [w for w in words if w.lower() not in _STOP_WORDS] or words - if not meaningful: - return title.strip()[:2].upper() or "?" - if len(meaningful) == 1: - # "[AV] Framework": a lone word borrows the tag's letter instead of - # its own second one, which reads as random. - tag = _WORD_RE.search(" ".join(_TAG_RE.findall(title))) - if tag and source is stripped: - return (tag.group()[0] + meaningful[0][0]).upper() - return meaningful[0][:2].upper() - return (meaningful[0][0] + meaningful[1][0]).upper() - - -def _blob( - painter: QPainter, cx: float, cy: float, radius: float, color: QColor -) -> None: - # Radial falloff stands in for a gaussian blur, which QPainter lacks. - grad = QRadialGradient(QPointF(cx, cy), radius) - inner = QColor(color) - outer = QColor(color) - outer.setAlpha(0) - grad.setColorAt(0.0, inner) - grad.setColorAt(1.0, outer) - painter.setBrush(grad) - painter.drawEllipse(QPointF(cx, cy), radius, radius) - - -@lru_cache(maxsize=16) -def generate_preview(title: str, width: int, height: int) -> QImage: - """Aurora: dark tinted base, two soft hue-shifted glows, ghosted initials. - - Returns a QImage: QPixmap painting is only safe on the GUI thread, and this - runs in a worker thread. - """ - image = QImage(width, height, QImage.Format.Format_ARGB32_Premultiplied) - hue = _seedrand(title) - image.fill(_hsl(hue, 0.40, 0.12)) - - painter = QPainter(image) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - painter.setPen(Qt.PenStyle.NoPen) - - span = max(width, height) - jitter_x = _seedrand(title + "jx") * 0.2 - jitter_y = _seedrand(title + "jy") * 0.2 - warm = _hsl(hue, 0.70, 0.50) - warm.setAlphaF(0.75) - cool = _hsl(hue + 0.14, 0.70, 0.50) - cool.setAlphaF(0.65) - _blob( - painter, width * (0.15 + jitter_x), height * (0.1 + jitter_y), span * 0.6, warm - ) - _blob( - painter, width * (0.85 - jitter_x), height * (0.9 - jitter_y), span * 0.55, cool - ) - - monogram = initials(title) - font = painter.font() - font.setBold(True) - font.setWeight(font.Weight.ExtraBold) - font.setPixelSize(max(32, int(height * 0.36))) - font.setLetterSpacing(font.SpacingType.PercentageSpacing, 96) - painter.setFont(font) - painter.setPen(QColor(255, 255, 255, 210)) - visible = max(height - BANNER_OVERLAY_HEIGHT, height // 2) - painter.drawText( - QRectF(0, 0, width, visible), Qt.AlignmentFlag.AlignCenter, monogram - ) - - painter.end() - return image diff --git a/src/pxmodrim/ui/components/progress_dialog.py b/src/pxmodrim/ui/components/progress_dialog.py deleted file mode 100644 index 2ee539e..0000000 --- a/src/pxmodrim/ui/components/progress_dialog.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -from typing import Self - -from PySide6.QtCore import QPropertyAnimation, Qt, QTimer -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QProgressBar, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.loading import LoadingState -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - -_HIDE_DELAY_MS = 300 -_FADE_IN_MS = 150 -_FADE_OUT_MS = 200 - - -class ProgressDialog(QDialog): - """Modal progress dialog showing stacked loading tasks as breadcrumbs.""" - - def __init__(self, loading: LoadingState, parent: QWidget) -> None: - super().__init__( - parent, - Qt.WindowType.WindowStaysOnTopHint - | Qt.WindowType.CustomizeWindowHint - | Qt.WindowType.WindowTitleHint, - ) - self.setWindowTitle("Loading") - self.setMinimumWidth(420) - self.setModal(True) - - self._loading = loading - self._shown = False - - self._crumbs = QWidget(self) - self._crumbs_layout = QHBoxLayout(self._crumbs) - self._crumbs_layout.setContentsMargins(0, 0, 0, 8) - self._crumbs_layout.setSpacing(4) - - self._progress = QProgressBar(self) - self._progress.setTextVisible(False) - self._progress.setRange(0, 100) - - layout = QVBoxLayout(self) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(0) - layout.addWidget(self._crumbs) - layout.addWidget(self._progress) - install_dialog_chrome(self, closable=False) - - self._hide_timer = QTimer(self) - self._hide_timer.setSingleShot(True) - self._hide_timer.setInterval(_HIDE_DELAY_MS) - self._hide_timer.timeout.connect(self._begin_hide) - - self._fade = QPropertyAnimation(self, b"windowOpacity", self) - self._fade.finished.connect(self._on_fade_finished) - - loading.changed.connect(self._on_changed) - loading.finished.connect(self._on_finished) - - @property - def loading(self) -> LoadingState: - return self._loading - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: object, - ) -> None: - # Dialog auto-closes via LoadingState.finished signal - pass - - def _on_changed(self, status: str, pct: int, cur: int, total: int) -> None: - self._hide_timer.stop() - if not self._shown: - self._shown = True - if not self.isVisible(): - self.setWindowOpacity(0.0) - self.show() - self._fade_to(1.0, _FADE_IN_MS) - - statuses = self._loading.statuses - if statuses: - self.setWindowTitle(statuses[0]) - self._update_breadcrumbs(statuses) - self._progress.setValue(pct) - - def _on_finished(self) -> None: - self._hide_timer.start() - - def _update_breadcrumbs(self, statuses: tuple[str, ...]) -> None: - while (item := self._crumbs_layout.takeAt(0)) is not None: - widget = item.widget() - if widget is not None: - widget.hide() - widget.deleteLater() - - last = len(statuses) - 1 - for i, status in enumerate(statuses): - label = QLabel(status, self._crumbs) - if i == last: - font = label.font() - font.setBold(True) - label.setFont(font) - self._crumbs_layout.addWidget(label) - - if i < last: - sep = QLabel(self._crumbs) - sep.setPixmap(icon("chevron", 12, PALETTE["TEXT_DIM"]).pixmap(12, 12)) - self._crumbs_layout.addWidget(sep) - - self._crumbs_layout.addStretch() - - def _begin_hide(self) -> None: - self._shown = False - self._fade_to(0.0, _FADE_OUT_MS) - - def _fade_to(self, end: float, duration: int) -> None: - self._fade.stop() - self._fade.setDuration(duration) - self._fade.setStartValue(self.windowOpacity()) - self._fade.setEndValue(end) - self._fade.start() - - def _on_fade_finished(self) -> None: - if not self._shown: - self.hide() diff --git a/src/pxmodrim/ui/components/svg_provider.py b/src/pxmodrim/ui/components/svg_provider.py deleted file mode 100644 index e5723f9..0000000 --- a/src/pxmodrim/ui/components/svg_provider.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from urllib.parse import unquote - -from PySide6.QtCore import QByteArray, QObject, QSize, Qt -from PySide6.QtGui import QPainter, QPixmap -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuick import QQuickImageProvider -from PySide6.QtQuickControls2 import QQuickStyle -from PySide6.QtSvg import QSvgRenderer - -from pxmodrim.ui.components.icons import svg_str -from pxmodrim.ui.theme.palette import PALETTE - - -class SvgIconProvider(QQuickImageProvider): - """Serves named SVG icons to QML via image://icons/?color=.""" - - def __init__(self) -> None: - super().__init__(QQuickImageProvider.Pixmap) # pyright: ignore[reportAttributeAccessIssue] - - def requestPixmap(self, id: str, _size: QSize, requested_size: QSize) -> QPixmap: - parts = id.split("?", 1) - icon_name = parts[0] - color = PALETTE["TEXT_MUTED"] - if len(parts) > 1: - for param in parts[1].split("&"): - if param.startswith("color="): - raw = param[6:] - color = unquote(raw) - if not color.startswith("#") and color.startswith("23"): - color = "#" + color[2:] - - try: - svg = svg_str(icon_name, color) - except KeyError: - svg = '' - - renderer = QSvgRenderer(QByteArray(svg.encode())) - - sz = requested_size if requested_size.isValid() else QSize(16, 16) - pix = QPixmap(sz) - pix.fill(Qt.GlobalColor.transparent) - painter = QPainter(pix) - renderer.render(painter) - painter.end() - return pix - - -def create_qml_engine(parent: QObject | None = None) -> QQmlEngine: - """Build a QQmlEngine with the ``icons`` image provider registered. - - Every QML surface in the app -- main window chrome and every plugin view - -- shares one engine via constructor DI, and that engine must carry the - ``icons`` provider or ``image://icons/..`` URLs fail to resolve - (AGENTS.md's icon convention). Centralizing construction here means a - caller can no longer forget the registration the way a bare - ``QQmlEngine()`` call would. - """ - if not QQuickStyle.name(): - QQuickStyle.setStyle("Basic") - engine = QQmlEngine(parent) - engine.addImageProvider("icons", SvgIconProvider()) - return engine diff --git a/src/pxmodrim/ui/components/toast.py b/src/pxmodrim/ui/components/toast.py deleted file mode 100644 index 9222fdf..0000000 --- a/src/pxmodrim/ui/components/toast.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from typing import Literal - -from PySide6.QtCore import QPropertyAnimation, Qt, QTimer, Signal -from PySide6.QtWidgets import ( - QGraphicsOpacityEffect, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, - QWidget, -) - -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.theme.palette import PALETTE - -ToastLevel = Literal["info", "success", "warning", "error"] - -_TOAST_LEVELS: dict[ToastLevel, tuple[str, str]] = { - "info": ("info", PALETTE["PRIMARY"]), - "success": ("check-circle", PALETTE["SUCCESS"]), - "warning": ("warning", PALETTE["WARNING"]), - "error": ("error", PALETTE["DANGER"]), -} - - -class Toast(QWidget): - dismissed = Signal() - - def __init__( - self, - message: str, - level: ToastLevel = "info", - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setObjectName("toast") - self.setFixedWidth(320) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False) - - icon_name, accent = _TOAST_LEVELS[level] - - self._opacity_effect = QGraphicsOpacityEffect(self) - self._opacity_effect.setOpacity(0.0) - self.setGraphicsEffect(self._opacity_effect) - - layout = QHBoxLayout(self) - layout.setContentsMargins(12, 10, 8, 10) - layout.setSpacing(8) - - icon_label = QLabel() - icon_label.setPixmap(icon(icon_name, 16, accent).pixmap(16, 16)) - icon_label.setFixedSize(16, 16) - layout.addWidget(icon_label) - - self._message_label = QLabel(message) - self._message_label.setObjectName("toastMessage") - self._message_label.setWordWrap(True) - layout.addWidget(self._message_label, 1) - - close_btn = QPushButton() - close_btn.setObjectName("toastClose") - close_btn.setIcon(icon("close", 10, PALETTE["TEXT_DIM"])) - close_btn.setFixedSize(20, 20) - close_btn.setToolTip("Dismiss") - close_btn.clicked.connect(self.dismiss) - layout.addWidget(close_btn) - - self._opacity_anim = QPropertyAnimation(self._opacity_effect, b"opacity") - self._opacity_anim.setDuration(200) - self._opacity_anim.setStartValue(0.0) - self._opacity_anim.setEndValue(1.0) - - self._fade_out_anim = QPropertyAnimation(self._opacity_effect, b"opacity", self) - self._fade_out_anim.setDuration(150) - self._fade_out_anim.setEndValue(0.0) - self._fade_out_anim.finished.connect(self.dismissed) - self._dismissing = False - - def show_with_animation(self) -> None: - self.show() - self.raise_() - self._opacity_anim.setDirection(QPropertyAnimation.Direction.Forward) - self._opacity_anim.start() - - def dismiss(self) -> None: - if self._dismissing: - return - self._dismissing = True - self._opacity_anim.stop() - self._fade_out_anim.setStartValue(self._opacity_effect.opacity()) - self._fade_out_anim.start() - - -class ToastManager(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("toastManager") - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False) - - self._layout = QVBoxLayout(self) - self._layout.setContentsMargins(0, 0, 16, 16) - self._layout.setSpacing(8) - self._layout.setAlignment( - Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignRight - ) - - self._active_toasts: list[Toast] = [] - - def show_toast( - self, - message: str, - level: ToastLevel = "info", - duration_ms: int = 3000, - ) -> None: - toast = Toast(message, level, self) - self._layout.addWidget(toast) - self._active_toasts.append(toast) - toast.dismissed.connect(lambda: self._remove(toast)) - - toast.show_with_animation() - - if duration_ms > 0: - QTimer.singleShot(duration_ms, toast, toast.dismiss) - - def _remove(self, toast: Toast) -> None: - toast.hide() - self._layout.removeWidget(toast) - toast.deleteLater() - if toast in self._active_toasts: - self._active_toasts.remove(toast) - - def info(self, message: str, duration: int = 3000) -> None: - self.show_toast(message, "info", duration) - - def success(self, message: str, duration: int = 3000) -> None: - self.show_toast(message, "success", duration) - - def warning(self, message: str, duration: int = 4000) -> None: - self.show_toast(message, "warning", duration) - - def error(self, message: str, duration: int = 5000) -> None: - self.show_toast(message, "error", duration) - - def resize_to_parent(self) -> None: - parent = self.parentWidget() - if parent: - self.setGeometry(parent.rect()) diff --git a/src/pxmodrim/ui/components/unity_rich_text.py b/src/pxmodrim/ui/components/unity_rich_text.py deleted file mode 100644 index 150fa93..0000000 --- a/src/pxmodrim/ui/components/unity_rich_text.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -import html -import math -import re -from urllib.parse import urlsplit - -UNITY_TAG_PATTERN = re.compile(r"\s]+))?\s*>") -UNITY_COLOR_PATTERN = re.compile(r"^(#?[0-9a-fA-F]{3,8}|[a-zA-Z]+)$") -UNITY_SIZE_PATTERN = re.compile(r"^([+-]?\d+(?:\.\d+)?)(px|em|%)?$") - - -class UnityRichTextConverter: - """Convert Unity rich text tags to Qt-compatible HTML subset.""" - - __slots__ = ("_tag_stack",) - - def __init__(self) -> None: - self._tag_stack: list[str] = [] - - def convert(self, text: str) -> str: - self._tag_stack.clear() - # Split text into segments: tags and text content - # Process tags first, escape text content between tags - result_parts = [] - last_end = 0 - - for match in UNITY_TAG_PATTERN.finditer(text): - # Text before this tag - escape it - if match.start() > last_end: - text_segment = text[last_end : match.start()] - result_parts.append(html.escape(text_segment)) - - # Process the tag - full = match.group(0) - tag_name = match.group(1).lower() - attr = match.group(2) - - if full.startswith("") - result = result.replace("\\n", "
    ") - return self._close_all_tags(result) - - def _handle_open_tag(self, tag: str, attr: str | None) -> str: - html_tag = self._unity_to_html_open(tag, attr) - if html_tag: - self._tag_stack.append(tag) - return html_tag - return "" - - def _handle_close_tag(self, tag: str) -> str: - tag = tag.lower() - if tag in self._tag_stack: - while self._tag_stack and self._tag_stack[-1] != tag: - self._tag_stack.pop() - if self._tag_stack: - self._tag_stack.pop() - return self._unity_to_html_close(tag) - return "" - - def _close_all_tags(self, text: str) -> str: - for tag in reversed(self._tag_stack): - text += self._unity_to_html_close(tag) - self._tag_stack.clear() - return text - - def _unity_to_html_open(self, tag: str, attr: str | None) -> str: - match tag: - case "b" | "bold": - return "" - case "i" | "italic": - return "" - case "u" | "underline": - return '' - case "s" | "strike" | "strikethrough": - return "" - case "color": - return self._color_tag(attr) - case "size": - return self._size_tag(attr) - case "align": - return self._align_tag(attr) - case "indent": - return self._indent_tag(attr) - case "mark": - return self._mark_tag(attr) - case "br": - return "
    " - case "sub": - return "" - case "sup": - return "" - case "a" | "link": - return self._link_tag(attr) - case ( - "noparse" - | "alpha" - | "font" - | "font-weight" - | "mspace" - | "style" - | "gradient" - | "cspace" - | "voffset" - | "pos" - | "width" - | "line-height" - | "line-indent" - | "margin" - | "margin-left" - | "margin-right" - | "uppercase" - | "lowercase" - | "smallcaps" - | "rotate" - | "sprite" - ): - # TODO: Implement these tags - return "" - case _: - return "" - - def _unity_to_html_close(self, tag: str) -> str: - match tag: - case "b" | "bold": - return "
    " - case "i" | "italic": - return "" - case "u" | "underline": - return "" - case "s" | "strike" | "strikethrough": - return "" - case "color": - return "" - case "size": - return "" - case "align": - return "" - case "indent": - return "" - case "mark": - return "" - case "sub": - return "" - case "sup": - return "" - case "a" | "link": - return "" - case _: - return "" - - def _color_tag(self, attr: str | None) -> str: - if not attr: - return '' - attr = attr.strip("\"'") - if UNITY_COLOR_PATTERN.match(attr): - return f'' - return '' - - def _normalize_color(self, color: str) -> str: - if color.startswith("#"): - hex_part = color[1:] - if len(hex_part) == 3: - return "#" + "".join(c * 2 for c in hex_part) - if len(hex_part) == 4: - return "#" + "".join(c * 2 for c in hex_part) - if len(hex_part) == 6: - return "#" + hex_part - if len(hex_part) == 8: - return "#" + hex_part[:6] - named_colors = { - "red": "#ff0000", - "green": "#00ff00", - "blue": "#0000ff", - "white": "#ffffff", - "black": "#000000", - "yellow": "#ffff00", - "cyan": "#00ffff", - "magenta": "#ff00ff", - "gray": "#808080", - "grey": "#808080", - "orange": "#ffa500", - "purple": "#800080", - } - return named_colors.get(color.lower(), "#ffffff") - - def _size_tag(self, attr: str | None) -> str: - if not attr: - return '' - attr = attr.strip("\"'") - m = UNITY_SIZE_PATTERN.match(attr) - if not m: - return '' - raw_value = m.group(1) - unit = m.group(2) or "px" - is_relative = raw_value.startswith(("+", "-")) - value = float(raw_value) - base_pt = 12.0 - if unit == "%": - pt = base_pt * value / 100 - elif unit == "em": - pt = base_pt * value - else: - pt = base_pt + value if is_relative else value - if not math.isfinite(pt): - pt = 13.0 - pt = min(max(pt, 1.0), 200.0) - pt_str = str(int(pt)) if pt == int(pt) else str(pt) - return f'' - - def _align_tag(self, attr: str | None) -> str: - if not attr: - return '
    ' - attr = attr.strip("\"'").lower() - align_map = { - "left": "left", - "center": "center", - "right": "right", - "justified": "justify", - "flush": "justify", - } - return f'
    ' - - def _indent_tag(self, attr: str | None) -> str: - if not attr: - return '
    ' - attr = attr.strip("\"'") - m = UNITY_SIZE_PATTERN.match(attr) - if not m: - return '
    ' - value = float(m.group(1)) - if not math.isfinite(value): - value = 20.0 - unit = m.group(2) or "px" - if unit == "%": - px = 400 * value / 100 - elif unit == "em": - px = 16 * value - else: - px = value - px = int(min(max(px, -10000.0), 10000.0)) - return f'
    ' - - def _mark_tag(self, attr: str | None) -> str: - if not attr: - return '' - attr = attr.strip("\"'") - color = self._normalize_color(attr) - if len(attr) == 9 and attr.startswith("#"): - alpha = int(attr[7:], 16) / 255 - return f'' - return f'' - - def _link_tag(self, attr: str | None) -> str: - if not attr: - return "" - attr = attr.strip("\"'") - parsed = urlsplit(attr) - if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: - return "" - return f'' - - -def unity_rich_text_to_html(text: str) -> str: - """Convert Unity rich text to Qt-compatible HTML.""" - return UnityRichTextConverter().convert(text) diff --git a/src/pxmodrim/ui/components/view_rail_panel.py b/src/pxmodrim/ui/components/view_rail_panel.py deleted file mode 100644 index 44213cd..0000000 --- a/src/pxmodrim/ui/components/view_rail_panel.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import Qt, QUrl, Signal, Slot -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QWidget - -from pxmodrim.ui.theme.palette import PALETTE - -_QML_DIR = Path(__file__).parent -_RAIL_QML = _QML_DIR / "IconRail.qml" - - -class ViewRailPanel(QQuickWidget): - currentChanged = Signal(int) - hovered = Signal(int) - settings_requested = Signal() - help_action_requested = Signal(str) - - def __init__( - self, - tabs: list[dict], - help_entries: list[dict[str, str]], - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - """Initialize the icon rail with tab entries.""" - super().__init__(qml_engine, parent) # type: ignore[arg-type] - self.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self.setMinimumWidth(48) - self.setMaximumWidth(220) - - qml_ctx = self.rootContext() - qml_ctx.setContextProperty("railPanel", self) - qml_ctx.setContextProperty("railModel", tabs) - qml_ctx.setContextProperty("railHelpEntries", help_entries) - self.setSource(QUrl.fromLocalFile(str(_RAIL_QML))) - - root = self.rootObject() - if root is not None: - root.tabSelected.connect(self._on_tab_selected) # type: ignore[attr-defined] - root.tabHovered.connect(self._on_tab_hovered) # type: ignore[attr-defined] - root.settingsRequested.connect(self.settings_requested) # type: ignore[attr-defined] - root.helpActionRequested.connect(self.help_action_requested) # type: ignore[attr-defined] - - @Slot(int) - def _on_tab_selected(self, index: int) -> None: - self.currentChanged.emit(index) - - @Slot(int) - def _on_tab_hovered(self, index: int) -> None: - self.hovered.emit(index) - - def set_current(self, index: int) -> None: - root = self.rootObject() - if root is not None: - root.setProperty("currentIndex", index) diff --git a/src/pxmodrim/ui/config.py b/src/pxmodrim/ui/config.py deleted file mode 100644 index 2c23e05..0000000 --- a/src/pxmodrim/ui/config.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from pxmodrim.core.config import ConfigService - -__all__ = ["UIPrefs", "UIPrefsService", "load_ui_prefs", "save_ui_prefs"] - - -def load_ui_prefs(config_service: ConfigService) -> UIPrefs: - """Load UI prefs from ``ui_prefs.json`` via config service.""" - return config_service.load("ui_prefs.json", UIPrefs) - - -def save_ui_prefs(prefs: UIPrefs, config_service: ConfigService) -> None: - """Save UI prefs to ``ui_prefs.json`` via config service.""" - config_service.save("ui_prefs.json", prefs) - - -class UIPrefsService: - """Owns UIPrefs lifecycle: load and save. - - Follows the Service protocol (setup). - """ - - __slots__ = ("_prefs", "_svc") - - def __init__(self, svc: ConfigService) -> None: - self._svc = svc - self._prefs: UIPrefs | None = None - - async def setup(self) -> None: - self._prefs = self._svc.load("ui_prefs.json", UIPrefs) - - def prefs(self) -> UIPrefs: - assert self._prefs is not None - return self._prefs - - def save_prefs(self) -> None: - if self._prefs is not None: - self._svc.save("ui_prefs.json", self._prefs) diff --git a/src/pxmodrim/ui/context.py b/src/pxmodrim/ui/context.py deleted file mode 100644 index 05cd402..0000000 --- a/src/pxmodrim/ui/context.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from typing import TYPE_CHECKING, ClassVar, Protocol - -from PySide6.QtWidgets import QWidget - -from pxmodrim.core.plugin import Plugin, PluginRegistry -from pxmodrim.ui.settings_section import SettingsSectionFactory -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - - from pxmodrim.core.context import CoreContext - - -class Notifier(Protocol): - def info(self, message: str, duration: int = ...) -> None: ... - def success(self, message: str, duration: int = ...) -> None: ... - def warning(self, message: str, duration: int = ...) -> None: ... - def error(self, message: str, duration: int = ...) -> None: ... - - -class RailView(QWidget): - """Base of widgets registered on the navigation rail.""" - - view_id: ClassVar[str] - icon_name: ClassVar[str] - label: ClassVar[str] - #: The download queue: the header's downloads button opens it, and the header - #: hides its own download progress while it is shown. - lists_downloads: ClassVar[bool] = False - - def open_route(self, path: tuple[str, ...]) -> None: - """Handle the part of a ``modrim:///…`` link after the view id.""" - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(parent) - - -class AppContext: - __slots__ = ( - "_core", - "_navigate", - "_plugins", - "_rail_views", - "_settings_sections", - "_toasts", - "_ui_prefs", - ) - - def __init__(self, core: CoreContext, ui_prefs: UIPrefs | None = None) -> None: - self._core = core - self._plugins = PluginRegistry() - self._rail_views: list[type[RailView]] = [] - self._settings_sections: list[SettingsSectionFactory] = [] - self._toasts: Notifier | None = None - self._navigate: Callable[[str], None] | None = None - self._ui_prefs = ui_prefs or UIPrefs() - - # ── Plugin system (UI layer) ────────────────────── - - def register_plugin(self, plugin: Plugin) -> None: - self._plugins.register(plugin) - - @property - def plugins(self) -> PluginRegistry: - return self._plugins - - # ── Rail views ──────────────────────────────────── - - def add_rail_view( - self, view_cls: type[RailView], *, position: int | None = None - ) -> None: - if position is None: - self._rail_views.append(view_cls) - else: - self._rail_views.insert(position, view_cls) - - @property - def rail_views(self) -> tuple[type[RailView], ...]: - return tuple(self._rail_views) - - # ── Settings sections ───────────────────────────── - - def add_settings_section(self, factory: SettingsSectionFactory) -> None: - """Show a plugin's section, built per dialog with the dialog as parent.""" - self._settings_sections.append(factory) - - @property - def settings_sections(self) -> tuple[SettingsSectionFactory, ...]: - return tuple(self._settings_sections) - - # ── Toasts ──────────────────────────────────────── - - @property - def toasts(self) -> Notifier | None: - return self._toasts - - @toasts.setter - def toasts(self, notifier: Notifier | None) -> None: - self._toasts = notifier - - # ── Navigation ──────────────────────────────────── - - def navigate(self, url: str) -> None: - """Open a ``modrim://[/…]`` link (no-op if the view is unknown).""" - if self._navigate is not None: - self._navigate(url) - - def set_navigator(self, navigate: Callable[[str], None] | None) -> None: - self._navigate = navigate - - # ── UI prefs ────────────────────────────────────── - - @property - def ui_prefs(self) -> UIPrefs: - return self._ui_prefs - - # ── Core access ─────────────────────────────────── - - @property - def core(self) -> CoreContext: - return self._core - - # ── Lifecycle ───────────────────────────────────── - - def setup_all(self) -> None: - self._core.plugins.setup_all(self._core) - core_names = self._core.plugins.names - self._plugins.setup_all(self, extra_deps=core_names) - - async def init_all(self) -> None: - await self._core.plugins.init_all(self._core) - await self._plugins.init_all(self) - - async def refresh_mods(self, full: bool = False) -> int: - await self._core.mod_service.reload(full=full) - return len(self._core.all_mods) - - async def shutdown_all(self) -> None: - await self._plugins.shutdown_all() - await self._core.plugins.shutdown_all() diff --git a/src/pxmodrim/ui/mod_selection.py b/src/pxmodrim/ui/mod_selection.py deleted file mode 100644 index 7f03719..0000000 --- a/src/pxmodrim/ui/mod_selection.py +++ /dev/null @@ -1,50 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.ui.panels.mod_info_panel import ModInfoPanel - - -class ModSelectionPresenter: - """Loads a selected mod's details into a ModInfoPanel for any view.""" - - def __init__(self, ctx: CoreContext, panel: ModInfoPanel) -> None: - self._ctx = ctx - self._panel = panel - self._generation = 0 - - async def show(self, uuid: str) -> None: - generation = self._generation + 1 - self._generation = generation - - if not uuid: - self._panel.clear() - return - - mod = self._ctx.all_mods.get(uuid) - if mod is None: - self._panel.clear() - return - - self._panel.show_mod(mod, self._ctx.diagnostics_service.issues_for(uuid)) - pid = getattr(mod, "package_id", None) - active_pids = self._resolve_active_pids() - - if generation != self._generation: - return - await self._panel.set_time_analytics( - str(pid) if pid is not None else None, - active_pids, - ) - - def clear(self) -> None: - self._generation += 1 - self._panel.clear() - - def _resolve_active_pids(self) -> list[str]: - active = set(self._ctx.active_uuids) - return [ - str(m.package_id).lower() - for m in self._ctx.all_mods.values() - if isinstance(m, AboutXmlMod) and m.uuid in active - ] diff --git a/src/pxmodrim/ui/models/__init__.py b/src/pxmodrim/ui/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/ui/models/impact.py b/src/pxmodrim/ui/models/impact.py deleted file mode 100644 index b030c2e..0000000 --- a/src/pxmodrim/ui/models/impact.py +++ /dev/null @@ -1,26 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.services.startup_impact_service.models import ( - IMPACT_HIGH_THRESHOLD_S, - IMPACT_WARN_THRESHOLD_S, -) -from pxmodrim.ui.theme.palette import PALETTE - - -def format_duration(seconds: float) -> str: - if seconds < 0.001: - return "0 ms" - if seconds < 1: - return f"{round(seconds * 1000)} ms" - if seconds < 60: - return f"{seconds:.2f} s" - minutes, secs = divmod(round(seconds), 60) - return f"{minutes}m {secs:02d}s" - - -def impact_color(seconds: float) -> str: - if seconds < IMPACT_WARN_THRESHOLD_S: - return PALETTE["SUCCESS"] - if seconds < IMPACT_HIGH_THRESHOLD_S: - return PALETTE["WARNING"] - return PALETTE["DANGER"] diff --git a/src/pxmodrim/ui/models/mod_list_model.py b/src/pxmodrim/ui/models/mod_list_model.py deleted file mode 100644 index 0cb7571..0000000 --- a/src/pxmodrim/ui/models/mod_list_model.py +++ /dev/null @@ -1,435 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, - Signal, -) - -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.ui.models.impact import format_duration, impact_color -from pxmodrim.ui.theme.palette import PALETTE - -if TYPE_CHECKING: - from pxmodrim.core.services.diagnostics_service import DiagnosticsService - - -def provider_label(provider_id: str, package_id: str = "") -> str: - pid = provider_id.lower().strip() - pkg = package_id.lower().strip() - if pid in {"steam", "downloaded"}: - return "Workshop" - if pid == "local": - return "Local" - if pid == "core": - if pkg and pkg != "ludeon.rimworld": - return "DLC" - return "Core" - if pkg.startswith("ludeon.rimworld.") and pkg != "ludeon.rimworld": - return "DLC" - if pkg == "ludeon.rimworld": - return "Core" - return pid.capitalize() if pid else "" - - -def section_name(checked: bool, active_count: int, inactive_count: int) -> str: - if checked or active_count == 0 or inactive_count == 0: - return "" - return f"Inactive ({inactive_count})" - - -@dataclass(slots=True) -class ModItem: - mod: ListedMod - uuid: str - checked: bool = False - provider_color: str = PALETTE["TEXT_DIM"] - startup_impact_s: float = 0.0 - load_index: int = -1 - provider_label: str = "" - - -class ModListModel(QAbstractListModel): - CheckStateRole = Qt.ItemDataRole.UserRole + 1 - PackageIdRole = Qt.ItemDataRole.UserRole + 2 - ModVersionRole = Qt.ItemDataRole.UserRole + 3 - ProviderColorRole = Qt.ItemDataRole.UserRole + 4 - UuidRole = Qt.ItemDataRole.UserRole + 5 - HasErrorRole = Qt.ItemDataRole.UserRole + 6 - HasWarningRole = Qt.ItemDataRole.UserRole + 7 - ErrorTooltipRole = Qt.ItemDataRole.UserRole + 8 - WarningTooltipRole = Qt.ItemDataRole.UserRole + 9 - StartupImpactRole = Qt.ItemDataRole.UserRole + 10 - LoadIndexRole = Qt.ItemDataRole.UserRole + 11 - IsActiveRole = Qt.ItemDataRole.UserRole + 12 - SectionNameRole = Qt.ItemDataRole.UserRole + 13 - ProviderLabelRole = Qt.ItemDataRole.UserRole + 14 - StartupImpactColorRole = Qt.ItemDataRole.UserRole + 15 - StartupImpactTextRole = Qt.ItemDataRole.UserRole + 16 - active_mods_changed = Signal() - active_count_changed = Signal() - - @Property(int, notify=active_count_changed) - def activeCount(self) -> int: - return self._active_count - - def __init__( - self, - provider_colors: dict[str, str], - diagnostics: DiagnosticsService | None = None, - parent: QObject | None = None, - ) -> None: - super().__init__(parent) - self._provider_colors = provider_colors - self._items: list[ModItem] = [] - self._active_count: int = 0 - self._diag = diagnostics - if diagnostics is not None: - diagnostics.diagnostics_summary_changed.connect( - self._on_diagnostics_summary_changed - ) - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return len(self._items) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() < 0 or index.row() >= len(self._items): - return None - - item = self._items[index.row()] - - if role == Qt.ItemDataRole.DisplayRole: - return item.mod.name - if role == self.CheckStateRole: - return Qt.CheckState.Checked if item.checked else Qt.CheckState.Unchecked - if role == self.PackageIdRole: - return str(item.mod.package_id) if isinstance(item.mod, AboutXmlMod) else "" - if role == self.ModVersionRole: - return item.mod.mod_version if isinstance(item.mod, AboutXmlMod) else "" - if role == self.ProviderColorRole: - return item.provider_color - if role == self.UuidRole: - return item.uuid - if role in { - self.HasErrorRole, - self.HasWarningRole, - self.ErrorTooltipRole, - self.WarningTooltipRole, - }: - diag = self._diag.summary_for(item.uuid) if self._diag else None - if role == self.HasErrorRole: - return 1 if diag is not None and diag.has_errors else 0 - if role == self.HasWarningRole: - return 1 if diag is not None and diag.has_warnings else 0 - if role == self.ErrorTooltipRole: - return diag.error_tooltip if diag is not None else "" - if role == self.WarningTooltipRole: - return diag.warning_tooltip if diag is not None else "" - if role == self.StartupImpactRole: - return item.startup_impact_s - if role == self.StartupImpactColorRole: - return impact_color(item.startup_impact_s) - if role == self.StartupImpactTextRole: - return format_duration(item.startup_impact_s) - if role == self.LoadIndexRole: - return item.load_index - if role == self.IsActiveRole: - return item.checked - if role == self.SectionNameRole: - return section_name( - item.checked, self._active_count, len(self._items) - self._active_count - ) - if role == self.ProviderLabelRole: - if item.provider_label: - return item.provider_label - pkg = str(getattr(item.mod, "package_id", "")) - return provider_label(item.mod.provider_id, pkg) - if ( - role == Qt.ItemDataRole.ToolTipRole - and hasattr(item.mod, "description") - and item.mod.description - ): - return item.mod.description - - return None - - def setData( - self, - index: QModelIndex | QPersistentModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if not index.isValid() or index.row() < 0 or index.row() >= len(self._items): - return False - - if role == self.CheckStateRole: - self.set_check_states([index.row()], value == Qt.CheckState.Checked) - return True - - return False - - def set_startup_impact(self, impacts: dict[str, float] | None = None) -> None: - if not self._items: - return - if impacts is None: - impacts = {} - for item in self._items: - mod = item.mod - pid = getattr(mod, "package_id", None) - item.startup_impact_s = ( - impacts.get(str(pid), 0.0) if pid is not None else 0.0 - ) - - top = self.index(0, 0) - bottom = self.index(len(self._items) - 1, 0) - self.dataChanged.emit( - top, - bottom, - [ - self.StartupImpactRole, - self.StartupImpactColorRole, - self.StartupImpactTextRole, - ], - ) - - def _on_diagnostics_summary_changed(self, diags: dict[str, Any]) -> None: - if not self._items: - return - old = getattr(self, "_last_diags", {}) or {} - self._last_diags = diags - changed = [ - row - for row, item in enumerate(self._items) - if old.get(item.uuid) != diags.get(item.uuid) - ] - if not changed: - return - top = self.index(min(changed), 0) - bottom = self.index(max(changed), 0) - self.dataChanged.emit( - top, - bottom, - [ - self.HasErrorRole, - self.HasWarningRole, - self.ErrorTooltipRole, - self.WarningTooltipRole, - ], - ) - - def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags | Qt.ItemFlag.ItemIsDropEnabled - return ( - Qt.ItemFlag.ItemIsEnabled - | Qt.ItemFlag.ItemIsSelectable - | Qt.ItemFlag.ItemIsDragEnabled - | Qt.ItemFlag.ItemIsUserCheckable - ) - - def roleNames(self) -> dict[int, QByteArray]: - return { - Qt.ItemDataRole.DisplayRole: QByteArray(b"name"), - self.CheckStateRole: QByteArray(b"checkState"), - self.PackageIdRole: QByteArray(b"packageId"), - self.ModVersionRole: QByteArray(b"modVersion"), - self.ProviderColorRole: QByteArray(b"providerColor"), - self.UuidRole: QByteArray(b"uuid"), - self.HasErrorRole: QByteArray(b"hasError"), - self.HasWarningRole: QByteArray(b"hasWarning"), - self.ErrorTooltipRole: QByteArray(b"errorTooltip"), - self.WarningTooltipRole: QByteArray(b"warningTooltip"), - self.StartupImpactRole: QByteArray(b"startupImpact"), - self.LoadIndexRole: QByteArray(b"loadIndex"), - self.IsActiveRole: QByteArray(b"isActive"), - self.SectionNameRole: QByteArray(b"sectionName"), - self.ProviderLabelRole: QByteArray(b"providerLabel"), - self.StartupImpactColorRole: QByteArray(b"startupImpactColor"), - self.StartupImpactTextRole: QByteArray(b"startupImpactText"), - Qt.ItemDataRole.ToolTipRole: QByteArray(b"toolTip"), - } - - def update_provider_colors(self, provider_colors: dict[str, str]) -> None: - self._provider_colors = provider_colors - - def move_row(self, source_row: int, target_row: int) -> bool: - if source_row == target_row: - return False - if not (0 <= source_row < len(self._items)): - return False - if not (0 <= target_row < len(self._items)): - return False - - parent = QModelIndex() - destination_child = target_row + 1 if source_row < target_row else target_row - self.beginMoveRows(parent, source_row, source_row, parent, destination_child) - item = self._items.pop(source_row) - self._items.insert(target_row, item) - self.endMoveRows() - changed = self._update_load_indices() - if changed: - top = self.index(min(changed), 0) - bottom = self.index(max(changed), 0) - self.dataChanged.emit( - top, - bottom, - [self.LoadIndexRole, self.SectionNameRole], - ) - return True - - def commitOrder(self, new_ordered_uuids: list[str]) -> None: - item_by_uuid = {item.uuid: item for item in self._items} - existing_uuids = [uuid for uuid in new_ordered_uuids if uuid in item_by_uuid] - - ordered_set = set(existing_uuids) - ordered_items = [item_by_uuid[uuid] for uuid in existing_uuids] - remaining = [item for item in self._items if item.uuid not in ordered_set] - # Keep the load_mods invariant: active block first, inactive block by name. - new_items = ( - ordered_items - + [item for item in remaining if item.checked] - + sorted( - (item for item in remaining if not item.checked), - key=lambda item: item.mod.name.lower(), - ) - ) - - if new_items == self._items: - return - - old_uuid_to_row = {item.uuid: i for i, item in enumerate(self._items)} - new_row_for_old = { - old_uuid_to_row[item.uuid]: i for i, item in enumerate(new_items) - } - - self.layoutAboutToBeChanged.emit() - persistent_indexes = self.persistentIndexList() - self._items = new_items - self._update_load_indices() - new_persistent = [ - self.index(new_row_for_old.get(index.row(), -1), 0) - for index in persistent_indexes - ] - if persistent_indexes: - self.changePersistentIndexList(persistent_indexes, new_persistent) - self.layoutChanged.emit() - - def _make_item(self, uuid: str, mod: ListedMod, checked: bool) -> ModItem: - return ModItem( - mod=mod, - uuid=uuid, - checked=checked, - provider_color=self._provider_colors.get( - mod.provider_id, PALETTE["TEXT_DIM"] - ), - provider_label=provider_label( - mod.provider_id, str(getattr(mod, "package_id", "")) - ), - ) - - def load_mods(self, mods: dict[str, ListedMod], active_uuids: list[str]) -> None: - self.beginResetModel() - try: - active_set = set(active_uuids) - inactive = sorted( - ((uuid, mod) for uuid, mod in mods.items() if uuid not in active_set), - key=lambda kv: kv[1].name.lower(), - ) - self._items = [ - self._make_item(uuid, mods[uuid], True) - for uuid in active_uuids - if uuid in mods - ] + [self._make_item(uuid, mod, False) for uuid, mod in inactive] - self._update_load_indices() - finally: - self.endResetModel() - - def active_uuids(self) -> list[str]: - return [item.uuid for item in self._items if item.checked] - - def get_item(self, row: int) -> ModItem | None: - if 0 <= row < len(self._items): - return self._items[row] - return None - - def get_item_by_uuid(self, uuid: str) -> ModItem | None: - for item in self._items: - if item.uuid == uuid: - return item - return None - - def set_checkable(self, uuids: frozenset[str], checked: bool) -> None: - if not uuids: - return - rows = [ - i - for i, it in enumerate(self._items) - if it.uuid in uuids and it.checked != checked - ] - self.set_check_states(rows, checked) - - def set_check_states(self, rows: list[int], checked: bool) -> None: - if not rows: - return - changed_rows: list[int] = [] - for row in rows: - if 0 <= row < len(self._items) and self._items[row].checked != checked: - self._items[row].checked = checked - changed_rows.append(row) - if not changed_rows: - return - load_changed = self._update_load_indices() - all_affected = set(changed_rows) | set(load_changed) - top = self.index(min(all_affected), 0) - bottom = self.index(len(self._items) - 1, 0) - self.dataChanged.emit( - top, - bottom, - [ - self.CheckStateRole, - self.LoadIndexRole, - self.IsActiveRole, - self.SectionNameRole, - ], - ) - self.active_mods_changed.emit() - - def _update_load_indices(self) -> list[int]: - changed: list[int] = [] - counter = 0 - for row, item in enumerate(self._items): - if item.checked: - counter += 1 - new_idx = counter - else: - new_idx = -1 - if item.load_index != new_idx: - item.load_index = new_idx - changed.append(row) - old_active = self._active_count - self._active_count = counter - if old_active != self._active_count: - self.active_count_changed.emit() - return changed - - @property - def active_count(self) -> int: - return self._active_count diff --git a/src/pxmodrim/ui/models/mod_list_proxy_model.py b/src/pxmodrim/ui/models/mod_list_proxy_model.py deleted file mode 100644 index 532c875..0000000 --- a/src/pxmodrim/ui/models/mod_list_proxy_model.py +++ /dev/null @@ -1,352 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QPersistentModelIndex, - Qt, - Signal, -) - -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.ui.models.mod_list_model import ModListModel, section_name - - -class ModListProxyModel(QAbstractListModel): - is_filtered_changed = Signal() - active_count_changed = Signal() - - @property - def is_filtered(self) -> bool: - return bool(self._search_text) or self._sidebar_uuids is not None - - @Property(bool, notify=is_filtered_changed) - def isFiltered(self) -> bool: - return self.is_filtered - - @Property(int, notify=active_count_changed) - def activeCount(self) -> int: - return self._visible_active_count - - def __init__(self, source_model: ModListModel, parent: Any = None) -> None: - super().__init__(parent) - self._source = source_model - self._search_text = "" - self._sidebar_uuids: set[str] | None = None - self._visible_uuids: list[str] = [] - self._source_row_for_uuid: dict[str, int] = {} - self._proxy_row_by_uuid: dict[str, int] = {} - self._in_proxy_move = False - self._visible_active_count: int = 0 - self._visible_inactive_count: int = 0 - - self._source.dataChanged.connect(self._on_source_data_changed) - self._source.layoutChanged.connect(self._on_source_layout_changed) - self._source.modelReset.connect(self._on_source_model_reset) - self._source.rowsMoved.connect(self._on_source_rows_moved) - self._source.rowsInserted.connect(self._on_source_rows_inserted) - self._source.rowsRemoved.connect(self._on_source_rows_removed) - - self._rebuild() - - def _rebuild(self) -> None: - self._rebuild_source_mapping() - self._visible_uuids = [ - uuid - for uuid, row in self._source_row_for_uuid.items() - if self._accept_item(self._source.get_item(row)) - ] - self._proxy_row_by_uuid = { - uuid: row for row, uuid in enumerate(self._visible_uuids) - } - self._update_visible_counts() - - def _rebuild_source_mapping(self) -> None: - self._source_row_for_uuid = { - item.uuid: row - for row in range(self._source.rowCount()) - if (item := self._source.get_item(row)) is not None - } - - def _accept_item(self, item: Any) -> bool: - if self._sidebar_uuids is not None and item.uuid not in self._sidebar_uuids: - return False - - if self._search_text: - name_match = self._search_text in item.mod.name.strip().casefold() - pid_match = ( - isinstance(item.mod, AboutXmlMod) - and self._search_text in str(item.mod.package_id).strip().casefold() - ) - if not (name_match or pid_match): - return False - - return True - - def proxy_row_for_uuid(self, uuid: str) -> int | None: - return self._proxy_row_by_uuid.get(uuid) - - def set_search_filter(self, search_text: str) -> None: - text = search_text.strip().casefold() - if self._search_text == text: - return - old_filtered = self.is_filtered - self._search_text = text - self._apply_filter_change() - if self.is_filtered != old_filtered: - self.is_filtered_changed.emit() - - def set_sidebar_filter(self, uuids: set[str] | None) -> None: - if self._sidebar_uuids == uuids: - return - old_filtered = self.is_filtered - self._sidebar_uuids = uuids - self._apply_filter_change() - if self.is_filtered != old_filtered: - self.is_filtered_changed.emit() - - def _apply_filter_change(self) -> None: - self._layout_rebuild() - - def _layout_rebuild(self) -> None: - persistent_indexes = self.persistentIndexList() - persistent_uuids = [ - self._visible_uuids[index.row()] - if index.isValid() and 0 <= index.row() < len(self._visible_uuids) - else None - for index in persistent_indexes - ] - self.layoutAboutToBeChanged.emit() - self._rebuild() - new_indexes = [ - self.index(self._proxy_row_by_uuid[uuid], 0) - if uuid is not None and uuid in self._proxy_row_by_uuid - else QModelIndex() - for uuid in persistent_uuids - ] - self.changePersistentIndexList(persistent_indexes, new_indexes) - self.layoutChanged.emit() - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return len(self._visible_uuids) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if ( - not index.isValid() - or index.row() < 0 - or index.row() >= len(self._visible_uuids) - ): - return None - source_row = self._source_row_for_uuid.get(self._visible_uuids[index.row()], -1) - if source_row < 0: - return None - if role == ModListModel.SectionNameRole: - item = self._source.get_item(source_row) - return section_name( - item is None or item.checked, - self._visible_active_count, - self._visible_inactive_count, - ) - return self._source.data(self._source.index(source_row, 0), role) - - def setData( - self, - index: QModelIndex | QPersistentModelIndex, - value: Any, - role: int = Qt.ItemDataRole.EditRole, - ) -> bool: - if ( - not index.isValid() - or index.row() < 0 - or index.row() >= len(self._visible_uuids) - ): - return False - source_row = self._source_row_for_uuid.get(self._visible_uuids[index.row()], -1) - if source_row < 0: - return False - return self._source.setData(self._source.index(source_row, 0), value, role) - - def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag: - if not index.isValid(): - return Qt.ItemFlag.NoItemFlags | Qt.ItemFlag.ItemIsDropEnabled - source_row = self._source_row_for_uuid.get(self._visible_uuids[index.row()], -1) - if source_row < 0: - return Qt.ItemFlag.NoItemFlags - return self._source.flags(self._source.index(source_row, 0)) - - def roleNames(self) -> dict[int, QByteArray]: - return self._source.roleNames() - - def mapToSource(self, proxy_index: QModelIndex) -> QModelIndex: - if ( - not proxy_index.isValid() - or proxy_index.row() < 0 - or proxy_index.row() >= len(self._visible_uuids) - ): - return QModelIndex() - source_row = self._source_row_for_uuid.get( - self._visible_uuids[proxy_index.row()], -1 - ) - if source_row < 0: - return QModelIndex() - return self._source.index(source_row, 0) - - def mapFromSource(self, source_index: QModelIndex) -> QModelIndex: - if not source_index.isValid(): - return QModelIndex() - item = self._source.get_item(source_index.row()) - if item is None: - return QModelIndex() - proxy_row = self.proxy_row_for_uuid(item.uuid) - if proxy_row is None: - return QModelIndex() - return self.index(proxy_row, 0) - - def move_row( - self, proxy_source: int, proxy_target: int, *, clamp_to_active: bool = False - ) -> bool: - if not (0 <= proxy_source < len(self._visible_uuids)): - return False - source_uuid = self._visible_uuids[proxy_source] - source_source_row = self._source_row_for_uuid.get(source_uuid, -1) - if clamp_to_active: - item = self._source.get_item(source_source_row) - if self.is_filtered or item is None or not item.checked: - return False - proxy_target = max(0, min(proxy_target, self._visible_active_count - 1)) - if proxy_source == proxy_target: - return False - if not (0 <= proxy_target < len(self._visible_uuids)): - return False - - source_target_row = self._source_row_for_uuid.get( - self._visible_uuids[proxy_target], -1 - ) - if source_source_row < 0 or source_target_row < 0: - return False - - destination_child = ( - proxy_target + 1 if proxy_source < proxy_target else proxy_target - ) - - # Source signals carry post-move rows; forwarding them before endMoveRows - # would make views apply the data to delegates still at pre-move rows. - self._in_proxy_move = True - try: - self.beginMoveRows( - QModelIndex(), - proxy_source, - proxy_source, - QModelIndex(), - destination_child, - ) - self._visible_uuids.pop(proxy_source) - self._visible_uuids.insert(proxy_target, source_uuid) - self._proxy_row_by_uuid = { - uuid: row for row, uuid in enumerate(self._visible_uuids) - } - self._source.move_row(source_source_row, source_target_row) - self._rebuild_source_mapping() - self.endMoveRows() - finally: - self._in_proxy_move = False - self.dataChanged.emit( - self.index(min(proxy_source, proxy_target), 0), - self.index(max(proxy_source, proxy_target), 0), - [ModListModel.LoadIndexRole, ModListModel.SectionNameRole], - ) - return True - - def _on_source_data_changed( - self, top_left: QModelIndex, bottom_right: QModelIndex, roles: list[int] - ) -> None: - if self._in_proxy_move: - return - if ( - not roles - or ModListModel.CheckStateRole in roles - or ModListModel.IsActiveRole in roles - ): - self._update_visible_counts() - first = top_left.row() - last = bottom_right.row() - proxy_rows: list[int] = [] - for source_row in range(first, last + 1): - item = self._source.get_item(source_row) - if item is None: - continue - proxy_row = self.proxy_row_for_uuid(item.uuid) - if proxy_row is not None: - proxy_rows.append(proxy_row) - if not proxy_rows: - return - top = self.index(min(proxy_rows), 0) - bottom = self.index(max(proxy_rows), 0) - self.dataChanged.emit(top, bottom, roles) - - def _on_source_layout_changed(self) -> None: - if self._in_proxy_move: - return - self._layout_rebuild() - - def _on_source_model_reset(self) -> None: - self.beginResetModel() - self._rebuild() - self.endResetModel() - - def _on_source_rows_moved( - self, - parent: QModelIndex, - start: int, - end: int, - destination: QModelIndex, - row: int, - ) -> None: - if self._in_proxy_move: - return - self._on_source_layout_changed() - - def _on_source_rows_inserted( - self, parent: QModelIndex, first: int, last: int - ) -> None: - if self._in_proxy_move: - return - self._on_source_layout_changed() - - def _on_source_rows_removed( - self, parent: QModelIndex, first: int, last: int - ) -> None: - if self._in_proxy_move: - return - self._on_source_layout_changed() - - def _update_visible_counts(self) -> None: - active = 0 - inactive = 0 - for uuid in self._visible_uuids: - source_row = self._source_row_for_uuid.get(uuid, -1) - item = self._source.get_item(source_row) - if item is not None: - if item.checked: - active += 1 - else: - inactive += 1 - changed = self._visible_active_count != active - self._visible_active_count = active - self._visible_inactive_count = inactive - if changed: - self.active_count_changed.emit() diff --git a/src/pxmodrim/ui/models/sidebar_model.py b/src/pxmodrim/ui/models/sidebar_model.py deleted file mode 100644 index 9b1fd63..0000000 --- a/src/pxmodrim/ui/models/sidebar_model.py +++ /dev/null @@ -1,249 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from typing import Any - -from PySide6.QtCore import ( - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - -from pxmodrim.ui.theme.palette import PALETTE - -# (badge_bg, badge_fg, icon_name, icon_color) -_ENTRY_TYPES: dict[str, tuple[str, str, str, str]] = { - "all": (PALETTE["ELEVATE_4"], PALETTE["TEXT_MUTED"], "grid", PALETTE["TEXT_DIM"]), - "active": ( - PALETTE["ELEVATE_4"], - PALETTE["TEXT_MUTED"], - "check-circle", - PALETTE["TEXT_DIM"], - ), - "inactive": ( - PALETTE["ELEVATE_4"], - PALETTE["TEXT_MUTED"], - "ban", - PALETTE["TEXT_DIM"], - ), - "errors": (PALETTE["DANGER_BG_QML"], PALETTE["DANGER"], "error", PALETTE["DANGER"]), - "warnings": ( - PALETTE["WARNING_BG_QML"], - PALETTE["WARNING"], - "warning", - PALETTE["WARNING"], - ), - "provider": ( - PALETTE["ELEVATE_4"], - PALETTE["TEXT_MUTED"], - "folder", - PALETTE["TEXT_DIM"], - ), -} - - -# Provider icon overrides -_PROVIDER_ICONS: dict[str, str] = { - "steam": "steam", - "downloaded": "steam", - "local": "folder", - "core": "grid", - "git": "git", -} - - -class SidebarItem: - __slots__ = ( - "badge_bg", - "badge_fg", - "count", - "entry", - "icon_color", - "icon_name", - "label", - "section_name", - ) - - def __init__( - self, - label: str, - count: int, - badge_bg: str, - badge_fg: str, - icon_name: str, - icon_color: str, - section_name: str, - entry: object, - ) -> None: - self.label = label - self.count = count - self.badge_bg = badge_bg - self.badge_fg = badge_fg - self.icon_name = icon_name - self.icon_color = icon_color - self.section_name = section_name - self.entry = entry - - -def _detect_entry_type(entry: object) -> str: - cls = type(entry).__name__ - if "Active" in cls and "Inactive" not in cls: - return "active" - if "Inactive" in cls: - return "inactive" - if "Error" in cls: - return "errors" - if "Warning" in cls: - return "warnings" - if "All" in cls: - return "all" - return "provider" - - -def _section_for(entry: object) -> str: - return "Providers" if _detect_entry_type(entry) == "provider" else "Status" - - -def _icon_for_provider(entry: object) -> str: - """Determine provider icon from entry data.""" - label = getattr(entry, "label", "").lower() - for keyword, icon_name in _PROVIDER_ICONS.items(): - if keyword in label: - return icon_name - return "folder" - - -def _style_for(entry: object) -> tuple[str, str, str, str]: - entry_type = _detect_entry_type(entry) - bg, fg, icon_name, icon_color = _ENTRY_TYPES[entry_type] - if entry_type == "provider": - icon_name = _icon_for_provider(entry) - return bg, fg, icon_name, icon_color - - -class SidebarModel(QAbstractListModel): - LabelRole = Qt.ItemDataRole.UserRole + 1 - CountRole = Qt.ItemDataRole.UserRole + 2 - BadgeBgRole = Qt.ItemDataRole.UserRole + 3 - BadgeFgRole = Qt.ItemDataRole.UserRole + 4 - IconRole = Qt.ItemDataRole.UserRole + 5 - IconColorRole = Qt.ItemDataRole.UserRole + 6 - SectionRole = Qt.ItemDataRole.UserRole + 7 - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._items: list[SidebarItem] = [] - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - if parent is None: - parent = QModelIndex() - if parent.isValid(): - return 0 - return len(self._items) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() >= len(self._items): - return None - - item = self._items[index.row()] - - if role == Qt.ItemDataRole.DisplayRole or role == self.LabelRole: - return item.label if item.label else "" - if role == self.CountRole: - return item.count if item.count is not None else 0 - if role == self.BadgeBgRole: - return item.badge_bg if item.badge_bg else "" - if role == self.BadgeFgRole: - return item.badge_fg if item.badge_fg else "" - if role == self.IconRole: - return item.icon_name if item.icon_name else "" - if role == self.IconColorRole: - return item.icon_color if item.icon_color else "" - if role == self.SectionRole: - return item.section_name if item.section_name else "" - - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - self.LabelRole: QByteArray(b"filterLabel"), - self.CountRole: QByteArray(b"count"), - self.BadgeBgRole: QByteArray(b"badgeBg"), - self.BadgeFgRole: QByteArray(b"badgeFg"), - self.IconRole: QByteArray(b"iconName"), - self.IconColorRole: QByteArray(b"iconColor"), - self.SectionRole: QByteArray(b"sectionName"), - } - - def set_entries(self, entries: Sequence[object]) -> None: - self.beginResetModel() - self._items = [] - for e in entries: - bg, fg, icon_name, icon_color = _style_for(e) - section = _section_for(e) - self._items.append( - SidebarItem( - label=getattr(e, "label", ""), - count=getattr(e, "count", 0), - badge_bg=bg, - badge_fg=fg, - icon_name=icon_name, - icon_color=icon_color, - section_name=section, - entry=e, - ) - ) - self.endResetModel() - - def update_entries(self, entries: Sequence[object]) -> None: - if len(entries) != len(self._items): - self.set_entries(entries) - return - - changed_roles: set[int] = set() - for i, entry in enumerate(entries): - item = self._items[i] - bg, fg, icon_name, icon_color = _style_for(entry) - - new_label = getattr(entry, "label", "") or "" - new_count = getattr(entry, "count", 0) - - if item.label != new_label: - item.label = new_label - changed_roles.add(self.LabelRole) - if item.count != new_count: - item.count = new_count - changed_roles.add(self.CountRole) - if item.badge_bg != bg: - item.badge_bg = bg - changed_roles.add(self.BadgeBgRole) - if item.badge_fg != fg: - item.badge_fg = fg - changed_roles.add(self.BadgeFgRole) - if item.icon_name != icon_name: - item.icon_name = icon_name - changed_roles.add(self.IconRole) - if item.icon_color != icon_color: - item.icon_color = icon_color - changed_roles.add(self.IconColorRole) - - item.entry = entry - - if changed_roles and self._items: - top = self.index(0, 0) - bottom = self.index(len(self._items) - 1, 0) - self.dataChanged.emit(top, bottom, list(changed_roles)) - - def get_entry(self, row: int) -> object | None: - if 0 <= row < len(self._items): - return self._items[row].entry - return None diff --git a/src/pxmodrim/ui/navigation.py b/src/pxmodrim/ui/navigation.py deleted file mode 100644 index 77822a9..0000000 --- a/src/pxmodrim/ui/navigation.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from urllib.parse import quote, unquote, urlsplit - -SCHEME = "modrim" -SETTINGS_VIEW_ID = "settings" - - -@dataclass(frozen=True, slots=True) -class Route: - view_id: str - path: tuple[str, ...] = () - - @property - def url(self) -> str: - return build_route(self.view_id, *self.path) - - -def build_route(view_id: str, *path: str) -> str: - segments = "".join(f"/{quote(part, safe='')}" for part in path) - return f"{SCHEME}://{view_id}{segments}" - - -def parse_route(url: str) -> Route | None: - """Parse ``modrim://[/…]``; anything else is not a route.""" - try: - parts = urlsplit(url) - except ValueError: - return None - if parts.scheme != SCHEME or not parts.netloc: - return None - path = tuple(unquote(part) for part in parts.path.split("/") if part) - return Route(parts.netloc, path) diff --git a/src/pxmodrim/ui/panels/__init__.py b/src/pxmodrim/ui/panels/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/ui/panels/about_panel.py b/src/pxmodrim/ui/panels/about_panel.py deleted file mode 100644 index 6925637..0000000 --- a/src/pxmodrim/ui/panels/about_panel.py +++ /dev/null @@ -1,318 +0,0 @@ -from __future__ import annotations - -import platform -from importlib.metadata import version -from importlib.resources import files as resource_files -from typing import TYPE_CHECKING - -from PySide6.QtCore import Qt, QUrl, qVersion -from PySide6.QtGui import QDesktopServices, QPalette, QPixmap -from PySide6.QtWidgets import ( - QApplication, - QDialog, - QFrame, - QGridLayout, - QHBoxLayout, - QLabel, - QScrollArea, - QTextBrowser, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.support import get_app_version -from pxmodrim.ui.components import AppButton -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.panels.upload_report_dialog import handle_upload_report - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.components.toast import ToastManager - - -class AboutPanel(QDialog): - _REPOSITORY_URL = "https://github.com/PyXiion/PxModRim" - _ISSUES_URL = f"{_REPOSITORY_URL}/issues" - _DEPENDENCIES = ( - ("aiosqlite", "MIT"), - ("httpx", "BSD-3-Clause"), - ("loguru", "MIT"), - ("lxml", "BSD-3-Clause"), - ("msgspec", "BSD-3-Clause"), - ("PySide6", "LGPL-3.0 / GPL-2.0 / GPL-3.0"), - ("qasync", "BSD-2-Clause"), - ("toposort", "Apache-2.0"), - ("ttimer", "MIT"), - ) - - def __init__( - self, - parent: QWidget | None, - *, - ctx: CoreContext, - toast_manager: ToastManager, - ) -> None: - super().__init__(parent) - self._ctx = ctx - self._toast_manager = toast_manager - self.setObjectName("aboutPanel") - self.setWindowTitle("About PxModRim") - self.setModal(True) - self.resize(700, 740) - self.setMinimumSize(640, 620) - - layout = QVBoxLayout(self) - layout.setContentsMargins(24, 24, 24, 20) - layout.setSpacing(18) - layout.addWidget(self._create_hero()) - layout.addWidget(self._create_project_story(), 1) - layout.addWidget(self._create_details()) - self._credits_dialog = self._create_credits_dialog() - layout.addLayout(self._create_actions()) - - disclaimer = QLabel( - "PxModRim is an unofficial fan-made tool. RimWorld is a trademark " - "of Ludeon Studios.", - self, - ) - disclaimer.setObjectName("aboutDisclaimer") - disclaimer.setWordWrap(True) - layout.addWidget(disclaimer) - - footer = QHBoxLayout() - footer.addStretch() - close_button = AppButton("Close", self) - close_button.setObjectName("primaryAction") - close_button.clicked.connect(self.reject) - footer.addWidget(close_button) - layout.addLayout(footer) - - def _create_hero(self) -> QFrame: - hero = QFrame(self) - hero.setObjectName("aboutHero") - layout = QHBoxLayout(hero) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(20) - - logo = QLabel(hero) - pixmap = QPixmap(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) - logo.setPixmap( - pixmap.scaled( - 96, - 96, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - layout.addWidget(logo, alignment=Qt.AlignmentFlag.AlignTop) - - identity = QVBoxLayout() - identity.setSpacing(6) - - title_row = QHBoxLayout() - title = QLabel("PxModRim", hero) - title.setObjectName("aboutTitle") - title_row.addWidget(title) - title_row.addStretch() - - version_badge = QLabel(f"v{get_app_version()}", hero) - version_badge.setObjectName("aboutVersionBadge") - title_row.addWidget(version_badge, alignment=Qt.AlignmentFlag.AlignVCenter) - identity.addLayout(title_row) - - tagline = QLabel("A friendly, modern mod manager for RimWorld.", hero) - tagline.setObjectName("aboutTagline") - identity.addWidget(tagline) - - description = QLabel( - "Scan, sort, resolve dependencies, and manage large mod lists easily.", - hero, - ) - description.setObjectName("aboutDescription") - description.setWordWrap(True) - identity.addWidget(description) - identity.addStretch() - - layout.addLayout(identity, stretch=1) - return hero - - def _create_project_story(self) -> QWidget: - section = QWidget(self) - layout = QVBoxLayout(section) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - heading = QLabel("THE PROJECT", section) - heading.setObjectName("aboutSectionTitle") - layout.addWidget(heading) - - story = QLabel( - "I used RimPy and RimSort for a long time myself, but with recent " - "updates it kept getting slower and worse — some features even broke " - "my game 🙁

    " - "I initially started working on a PR to fix most of the parallelism " - "issues in RimSort, but realized it didn't make much sense. So I decided " - "to try creating my own alternative with the help of AI. The result " - "is what you see on your screen.

    " - "This is all just my personal opinion based on my experience with " - "RimSort at the time, and things may have changed for the better since " - "then (I hope so!).

    " - "— PyXiion", - ) - story.setObjectName("aboutStory") - story.setWordWrap(True) - story.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) - - scroll = QScrollArea(section) - scroll.setFrameShape(QFrame.Shape.NoFrame) - scroll.setWidgetResizable(True) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - scroll.setWidget(story) - scroll.viewport().setAutoFillBackground(False) - scroll.viewport().setBackgroundRole(QPalette.ColorRole.NoRole) - story.setAutoFillBackground(False) - story.setBackgroundRole(QPalette.ColorRole.NoRole) - layout.addWidget(scroll, 1) - return section - - def _create_details(self) -> QFrame: - details = QFrame(self) - details.setObjectName("aboutDetails") - layout = QGridLayout(details) - layout.setContentsMargins(16, 14, 16, 14) - layout.setHorizontalSpacing(14) - layout.setVerticalSpacing(8) - - environment = " • ".join(self._environment()) - rows = ( - ("CREATED BY", "PyXiion"), - ("LICENSE", "GPL-3.0-or-later"), - ("INSPIRED BY", "RimSort"), - ("ENVIRONMENT", environment), - ) - for row, (label_text, value_text) in enumerate(rows): - label = QLabel(label_text, details) - label.setObjectName("aboutMetaLabel") - layout.addWidget(label, row, 0) - - value = QLabel(value_text, details) - value.setObjectName("aboutMetaValue") - value.setWordWrap(True) - layout.addWidget(value, row, 1) - - layout.setColumnStretch(1, 1) - return details - - def _create_actions(self) -> QHBoxLayout: - actions = QHBoxLayout() - actions.setSpacing(8) - - github_button = AppButton("GitHub", self) - github_button.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl(self._REPOSITORY_URL)) - ) - actions.addWidget(github_button) - - issues_button = AppButton("Report issue", self) - issues_button.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl(self._ISSUES_URL)) - ) - actions.addWidget(issues_button) - - self._copy_button = AppButton("Copy system info", self) - self._copy_button.clicked.connect(self._copy_system_information) - actions.addWidget(self._copy_button) - - self._upload_button = AppButton("Upload log && system info…", self) - self._upload_button.clicked.connect(self._on_upload_clicked) - actions.addWidget(self._upload_button) - - credits_button = AppButton("Open-source credits", self) - credits_button.clicked.connect(self._credits_dialog.open) - actions.addWidget(credits_button) - actions.addStretch() - return actions - - def _create_credits_dialog(self) -> QDialog: - dialog = QDialog(self) - dialog.setObjectName("creditsDialog") - dialog.setWindowTitle("Open-Source Credits") - dialog.setModal(True) - dialog.resize(560, 500) - - layout = QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 16) - layout.setSpacing(14) - - title = QLabel("Open-source credits", dialog) - title.setObjectName("creditsTitle") - layout.addWidget(title) - - credits = QTextBrowser(dialog) - credits.setObjectName("creditsBrowser") - credits.setOpenExternalLinks(True) - credits.setHtml(self._credits_html()) - layout.addWidget(credits) - - footer = QHBoxLayout() - footer.addStretch() - close_button = AppButton("Close", dialog) - close_button.clicked.connect(dialog.reject) - footer.addWidget(close_button) - layout.addLayout(footer) - install_dialog_chrome(dialog) - return dialog - - def _credits_html(self) -> str: - dependencies = "".join( - "" - f'
    {name}' - f"{version(name)}" - f"{license_name}" - "" - for name, license_name in self._DEPENDENCIES - ) - return f""" -

    PxModRim

    -

    Created and maintained by PyXiion.

    -

    RimSort

    -

    - Portions are derived from - RimSort - and used under the MIT license. -

    -

    Runtime dependencies

    - - - - - - - {dependencies} -
    PackageVersionLicense
    - """ - - def _copy_system_information(self) -> None: - QApplication.clipboard().setText(self._system_information()) - self._toast_manager.success("System info copied") - - @staticmethod - def _environment() -> tuple[str, ...]: - return ( - f"Python {platform.python_version()}", - f"PySide6 {version('PySide6')}", - f"Qt {qVersion()}", - f"{platform.system()} {platform.release()} ({platform.machine()})", - ) - - def _system_information(self) -> str: - return "\n".join((f"PxModRim {get_app_version()}", *self._environment())) - - @asyncSlot() - async def _on_upload_clicked(self) -> None: - self._upload_button.setEnabled(False) - try: - await handle_upload_report(self, self._ctx, self._toast_manager) - finally: - self._upload_button.setEnabled(True) diff --git a/src/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py b/src/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py deleted file mode 100644 index 3eb5c28..0000000 --- a/src/pxmodrim/ui/panels/keyboard_shortcuts_dialog.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QHeaderView, - QTableWidget, - QTableWidgetItem, - QVBoxLayout, - QWidget, -) - -from pxmodrim.ui.components.button import AppButton - -QML_SHORTCUTS = ( - ("Up / Down", "Navigate mod list"), - ("Shift+Up / Shift+Down", "Extend mod selection"), - ("Return / Space", "Toggle selected mods"), - ("Ctrl+A", "Select all mods"), -) - - -class KeyboardShortcutsDialog(QDialog): - def __init__( - self, rows: tuple[tuple[str, str], ...], parent: QWidget | None = None - ) -> None: - super().__init__(parent) - self.setWindowTitle("Keyboard Shortcuts") - self.resize(520, 600) - layout = QVBoxLayout(self) - table = QTableWidget(len(rows), 2, self) - table.setHorizontalHeaderLabels(("Shortcut", "Action")) - table.verticalHeader().hide() - table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) - table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) - table.horizontalHeader().setSectionResizeMode( - 0, QHeaderView.ResizeMode.ResizeToContents - ) - table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - for row, (shortcut, action) in enumerate(rows): - table.setItem(row, 0, QTableWidgetItem(shortcut)) - table.setItem(row, 1, QTableWidgetItem(action)) - layout.addWidget(table) - - buttons = QHBoxLayout() - buttons.addStretch() - close_btn = AppButton("Close", self) - close_btn.setObjectName("primaryAction") - close_btn.setDefault(True) - close_btn.clicked.connect(self.accept) - buttons.addWidget(close_btn) - layout.addLayout(buttons) diff --git a/src/pxmodrim/ui/panels/mod_info_data.py b/src/pxmodrim/ui/panels/mod_info_data.py deleted file mode 100644 index 9f57671..0000000 --- a/src/pxmodrim/ui/panels/mod_info_data.py +++ /dev/null @@ -1,297 +0,0 @@ -from __future__ import annotations - -import html -import re -import time -from typing import TYPE_CHECKING, Any - -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.ui.components.unity_rich_text import unity_rich_text_to_html -from pxmodrim.ui.models.mod_list_model import provider_label - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.models.view.diagnostics import ModIssueView - -_URL_RE = re.compile(r"https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>\"])+") -_IMG_RE = re.compile(r"\[img\]((https?)://[^\s\[<\"]+)\[/img\]", re.IGNORECASE) -_URL_TAG_RE = re.compile( - r"\[url=(https?://[^\]\s\"<]+)\](.*?)\[/url\]", re.IGNORECASE | re.DOTALL -) -_BARE_URL_TAG_RE = re.compile(r"\[url\](https?://[^\[\s\"<]+)\[/url\]", re.IGNORECASE) -_ENTITY_RE = re.compile(r"&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});") -_QUOTE_AUTHOR_RE = re.compile(r"\[quote=([^\]]+)\]", re.IGNORECASE) -_TABLE_OPEN_RE = re.compile(r"\[table[^\]]*\]", re.IGNORECASE) -# BBCode name -> HTML tag, for tags that map one-to-one. -_PAIRED_TAGS = { - "b": "b", - "i": "i", - "u": "u", - "s": "s", - "strike": "s", - "h1": "h1", - "h2": "h2", - "h3": "h3", - "code": "pre", - "quote": "blockquote", - "list": "ul", - "olist": "ol", - "table": "table", - "tr": "tr", - "td": "td", - "th": "th", -} -_PAIRED_RE = re.compile(r"\[(/?)(" + "|".join(_PAIRED_TAGS) + r")\]", re.IGNORECASE) -# Tags that only exist to be stripped (Steam spoiler/noparse have no HTML form). -_STRIP_RE = re.compile(r"\[/?(?:spoiler|noparse)\]", re.IGNORECASE) -_BLOCK = r"(?:ul|ol|li|table|tr|td|th|blockquote|pre|h[1-3])" -_BLOCK_AFTER = r"(?:/h[1-3]|/?ul|/?ol|/?table|/?tr|/?blockquote|/?pre)" -# Line breaks the text converter emits around block tags would add blank lines. -_BR_BEFORE_BLOCK_RE = re.compile(r"(?:
    \s*)+(?=)(?:
    \s*)+") -# Replaced by the QML page with the description's pixel width. -IMG_WIDTH_TOKEN = "__IMG_WIDTH__" -_TAG_RE = re.compile(r"(<[^>]+>)") -_TRAILING_PUNCT = ".,;:!?)" -_UNITS = ( - ("year", 365 * 86400), - ("month", 30 * 86400), - ("week", 7 * 86400), - ("day", 86400), - ("hour", 3600), -) - - -def bbcode_images(html_text: str) -> str: - """Turn Steam-style [img]url[/img] into width-constrained tags. - - Only https images are loaded; plain-http ones become a link instead. - """ - - def image(m: re.Match[str]) -> str: - url = m.group(1) - if m.group(2).lower() == "https": - return f'' - return f'{url}' - - return _IMG_RE.sub(image, html_text) - - -def restore_entities(html_text: str) -> str: - """Un-escape entities like • that authors wrote into plain-text fields.""" - return _ENTITY_RE.sub(r"&\1;", html_text) - - -def bbcode_markup(html_text: str) -> str: - """Convert Steam-style BBCode (links, formatting, lists, tables) to HTML.""" - html_text = _URL_TAG_RE.sub(r'\2', html_text) - html_text = _BARE_URL_TAG_RE.sub(r'\1', html_text) - html_text = _QUOTE_AUTHOR_RE.sub(r"
    \1 wrote:
    ", html_text) - html_text = _TABLE_OPEN_RE.sub("", html_text) - html_text = _STRIP_RE.sub("", html_text) - html_text = re.sub(r"\[hr\]", "
    ", html_text, flags=re.IGNORECASE) - html_text = html_text.replace("[*]", "
  • ") - html_text = _PAIRED_RE.sub( - lambda m: f"<{m.group(1)}{_PAIRED_TAGS[m.group(2).lower()]}>", html_text - ) - html_text = _BR_BEFORE_BLOCK_RE.sub("", html_text) - return _BR_AFTER_BLOCK_RE.sub(r"\1", html_text) - - -def autolink(html_text: str) -> str: - """Wrap bare http(s) URLs in anchors, leaving text inside existing alone.""" - - def link(match: re.Match[str]) -> str: - url = match.group(0) - stripped = url.rstrip(_TRAILING_PUNCT) - return f'{stripped}{url[len(stripped) :]}' - - out = [] - in_anchor = False - for part in _TAG_RE.split(html_text): - if part.startswith("<"): - lowered = part.lower() - if lowered.startswith("": - in_anchor = True - elif lowered == "": - in_anchor = False - out.append(part) - else: - out.append(part if in_anchor else _URL_RE.sub(link, part)) - return "".join(out) - - -def description_to_html(text: str, description_format: str = "bbcode") -> str: - """Render descriptions as safe Qt rich text, preserving plain-text content.""" - if description_format != "bbcode": - return html.escape(text).replace("\n", "
    ") - return autolink( - bbcode_markup(bbcode_images(restore_entities(unity_rich_text_to_html(text)))) - ) - - -def format_age(mtime: float, now: float | None = None) -> str: - if mtime <= 0: - return "" - delta = (time.time() if now is None else now) - mtime - for name, seconds in _UNITS: - if delta >= seconds: - count = int(delta // seconds) - return f"{count} {name}{'s' if count != 1 else ''} ago" - return "just now" - - -def _issue_dicts(issues: list[ModIssueView]) -> list[dict[str, Any]]: - return [ - { - "title": i.category_display_name, - "detail": i.detail or "", - "isError": i.is_error, - } - for i in issues - ] - - -def _status_title(issues: list[ModIssueView]) -> tuple[str, str]: - errors = sum(1 for i in issues if i.is_error) - warnings = len(issues) - errors - if errors: - return "error", f"{errors} error{'s' if errors != 1 else ''}" - if warnings: - return "warning", f"{warnings} warning{'s' if warnings != 1 else ''}" - return "ok", "No issues" - - -def _pid_index(ctx: CoreContext) -> dict[str, ListedMod]: - index: dict[str, ListedMod] = {} - for m in ctx.all_mods.values(): - if isinstance(m, AboutXmlMod): - index.setdefault(str(m.package_id).lower(), m) - return index - - -def _needs( - mod: AboutXmlMod, index: dict[str, ListedMod], active: set[str] -) -> list[dict[str, str]]: - result = [] - for dep in mod.about_rules.dependencies.values(): - candidates = [str(dep.package_id), *map(str, dep.alternative_package_ids)] - found = next((index[c.lower()] for c in candidates if c.lower() in index), None) - if found is None: - state = "missing" - elif found.uuid in active: - state = "active" - else: - state = "inactive" - result.append( - { - "name": dep.name or str(dep.package_id), - "state": state, - "uuid": found.uuid if found is not None else "", - } - ) - return result - - -def _conflicts( - mod: AboutXmlMod, index: dict[str, ListedMod], active: set[str] -) -> list[str]: - names = [] - for pid in mod.about_rules.incompatible_with: - other = index.get(str(pid).lower()) - if other is not None and other.uuid in active: - names.append(other.name) - return sorted(names, key=str.lower) - - -def _needed_by(mod: AboutXmlMod, ctx: CoreContext) -> list[dict[str, str]]: - pid = str(mod.package_id).lower() - found = [ - m - for m in ctx.all_mods.values() - if isinstance(m, AboutXmlMod) - and m.uuid != mod.uuid - and any( - pid - in (str(d.package_id).lower(), *map(str.lower, d.alternative_package_ids)) - for d in m.about_rules.dependencies.values() - ) - ] - found.sort(key=lambda m: m.name.lower()) - return [{"name": m.name, "uuid": m.uuid} for m in found] - - -def _version_support(mod: ListedMod, target: str) -> dict[str, Any]: - if not mod.supported_versions or target == "Unknown": - return {"known": False, "ok": False, "target": target} - return {"known": True, "ok": target in mod.supported_versions, "target": target} - - -def _details(mod: ListedMod, last_synced: float | None = None) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - if isinstance(mod, AboutXmlMod): - if mod.authors: - rows.append({"label": "Author", "value": ", ".join(mod.authors)}) - if mod.mod_version: - rows.append({"label": "Version", "value": mod.mod_version}) - source = provider_label(mod.provider_id, str(mod.package_id)) - if source: - rows.append({"label": "Source", "value": source}) - if mod.mtime > 0: - rows.append({"label": "Updated", "value": format_age(mod.mtime)}) - if last_synced: - rows.append({"label": "Last synced", "value": format_age(last_synced)}) - rows.append({"label": "Package ID", "value": str(mod.package_id), "copy": True}) - if mod.mod_path is not None: - rows.append({"label": "Path", "value": str(mod.mod_path), "copy": True}) - return rows - - -def build_mod_info( - ctx: CoreContext, mod: ListedMod, issues: list[ModIssueView] -) -> dict[str, Any]: - active_list = ctx.active_uuids - active = set(active_list) - is_active = mod.uuid in active - level, title = _status_title(issues) - is_about = isinstance(mod, AboutXmlMod) - - needs: list[dict[str, str]] = [] - needed_by: list[dict[str, str]] = [] - conflicts: list[str] = [] - if isinstance(mod, AboutXmlMod): - index = _pid_index(ctx) - needs = _needs(mod, index, active) - conflicts = _conflicts(mod, index, active) - needed_by = _needed_by(mod, ctx) - - downloads = download_manager(ctx) - source = downloads.source_of(mod) - return { - "name": mod.name, - "author": ", ".join(mod.authors) if isinstance(mod, AboutXmlMod) else "", - "provider": ( - provider_label(mod.provider_id, str(mod.package_id)) if is_about else "" - ), - "status": { - "level": level, - "title": title, - "active": is_active, - "position": active_list.index(mod.uuid) + 1 if is_active else 0, - "total": len(active_list), - "issues": _issue_dicts(issues), - "obsolete": mod.obsolete, - "valid": mod.valid, - }, - "version": _version_support(mod, ctx.target_version), - "needs": needs, - "conflicts": conflicts, - "neededBy": needed_by, - "description": description_to_html(mod.description), - "details": _details(mod, downloads.last_synced(mod)), - "canOpenFolder": mod.mod_path is not None and mod.mod_path.exists(), - "canUpdate": source is not None, - "updateSource": source.label if source is not None else "", - "url": mod.url if isinstance(mod, AboutXmlMod) else "", - } diff --git a/src/pxmodrim/ui/panels/mod_info_panel.py b/src/pxmodrim/ui/panels/mod_info_panel.py deleted file mode 100644 index 545b813..0000000 --- a/src/pxmodrim/ui/panels/mod_info_panel.py +++ /dev/null @@ -1,328 +0,0 @@ -from __future__ import annotations - -import asyncio -import html -import re -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from loguru import logger -from PySide6.QtCore import QMetaObject, Qt, QUrl, Signal -from PySide6.QtGui import QColor, QDesktopServices, QGuiApplication, QImage, QPixmap -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QLabel, QVBoxLayout, QWidget -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.models.metadata.structures import AboutXmlMod, ListedMod -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.ui.components import AspectRatioBanner, generate_preview -from pxmodrim.ui.config import save_ui_prefs -from pxmodrim.ui.models.impact import format_duration, impact_color -from pxmodrim.ui.panels.mod_info_data import build_mod_info -from pxmodrim.ui.panels.time_analytics_panel import StartupImpactDialog -from pxmodrim.ui.theme.constants import BANNER_MAX_HEIGHT -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from pxmodrim.core.models.view.diagnostics import ModIssueView - -_QML = Path(__file__).parent / "ModInfo.qml" -_TOP_METRICS = 3 -_OPENABLE_SCHEMES = frozenset({"http", "https"}) -_NO_STARTUP = {"available": False, "message": "Startup impact is unavailable."} - - -_MARKUP_RE = re.compile( - r"\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\]", - re.IGNORECASE | re.DOTALL, -) - - -def _first_sentence(text: str, max_len: int = 80) -> str: - if not text: - return "" - stripped = " ".join(html.unescape(_MARKUP_RE.sub(" ", text)).split()) - end = stripped.find(".") - result = stripped[: end + 1] if end != -1 else stripped - if len(result) > max_len: - result = result[:max_len].rsplit(" ", 1)[0] + "\u2026" - return result - - -def _find_preview(mod_path: Path | None) -> Path | None: - if mod_path is None: - return None - candidate = mod_path / "About" / "Preview.png" - return candidate if candidate.exists() else None - - -class ModInfoPanel(QWidget): - mod_requested = Signal(str) - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - ui_prefs: UIPrefs | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._ctx = ctx - self._qml_engine = qml_engine - self._ui_prefs = ui_prefs or UIPrefs() - self._mod: ListedMod | None = None - self._current_mod_id: str | None = None - self._preview_task: asyncio.Task[None] | None = None - self._startup_token = 0 - self._startup_args: tuple[str | None, list[str]] = (None, []) - self._description = "" - self._startup_dialog: StartupImpactDialog | None = None - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._banner = AspectRatioBanner(self, max_height=BANNER_MAX_HEIGHT) - self._banner.hide() - layout.addWidget(self._banner, 0, Qt.AlignmentFlag.AlignTop) - - self._placeholder = QLabel("Select a mod to view details", self) - self._placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._placeholder.setObjectName("placeholder") - layout.addWidget(self._placeholder, 0, Qt.AlignmentFlag.AlignTop) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self._qml.setSource(QUrl.fromLocalFile(str(_QML))) - self._qml.hide() - layout.addWidget(self._qml, 1) - - self._downloads = download_manager(ctx) - - root: Any = self._qml.rootObject() - if root is None: - logger.warning("ModInfo QML failed to load: {}", _QML) - return - root.setProperty("descExpanded", self._ui_prefs.desc_expanded) - root.openFolder.connect(self._on_open_folder) - root.openUrl.connect(self._on_open_url) - root.selectMod.connect(self.mod_requested) - root.copyText.connect(self._on_copy_text) - root.openStartupDetails.connect(self._on_open_startup_details) - root.descToggled.connect(self._on_desc_toggled) - root.updateMods.connect(self._on_update_mod) - root.setProperty("downloadsBusy", self._downloads.is_downloading) - self._downloads.busy_changed.connect(self._on_downloads_busy) - - def _set_qml(self, name: str, value: object) -> None: - root = self._qml.rootObject() - if root is None: - logger.warning("ModInfo QML root object is missing; dropping '{}'", name) - return - root.setProperty(name, value) - - def show_mod(self, mod: ListedMod, issues: list[ModIssueView]) -> None: - if self._preview_task and not self._preview_task.done(): - self._preview_task.cancel() - - mod_id = getattr(mod, "package_id", None) - if mod_id is None: - mod_id = mod.name - - self._mod = mod - self._current_mod_id = mod_id - - self._placeholder.hide() - self._banner.show() - self._qml.show() - - self._banner.setTitle(mod.name) - self._banner.setSubtitle( - _first_sentence(mod.description) - if mod.description - else (str(mod.package_id) if isinstance(mod, AboutXmlMod) else "") - ) - - self._set_qml("startup", None) - self.set_issues(issues) - self._sync_updating() - root: Any = self._qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, "resetScroll") - - self._preview_task = asyncio.ensure_future( - self._load_preview(mod.mod_path, mod_id, mod.name) - ) - - def set_issues(self, issues: list[ModIssueView]) -> None: - if self._mod is None: - return - data = build_mod_info(self._ctx, self._mod, issues) - description = data.pop("description") - self._set_qml("info", data) - if description != self._description: - self._description = description - self._set_qml("description", description) - - def _on_downloads_busy(self, busy: bool) -> None: - self._set_qml("downloadsBusy", busy) - self._sync_updating() - - def _sync_updating(self) -> None: - mod_id = self._downloads.updatable_id(self._mod) if self._mod else None - updating = mod_id is not None and mod_id in self._downloads.active_ids - self._set_qml("updatingThis", updating) - - @asyncSlot() - async def _on_update_mod(self) -> None: - if self._mod is None: - return - pid = self._downloads.updatable_id(self._mod) - if pid is None: - return - try: - await self._downloads.download_mods([pid]) - except (RuntimeError, ValueError) as exc: - logger.warning("[downloads] update of {} not started: {}", pid, exc) - - def _on_open_folder(self) -> None: - if self._mod is not None and self._mod.mod_path is not None: - QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._mod.mod_path))) - - def _on_open_url(self) -> None: - if not isinstance(self._mod, AboutXmlMod) or not self._mod.url: - return - url = QUrl(self._mod.url) - if url.scheme().lower() not in _OPENABLE_SCHEMES: - logger.warning("Refusing to open non-http(s) mod URL: {}", self._mod.url) - return - QDesktopServices.openUrl(url) - - def _on_copy_text(self, text: str) -> None: - QGuiApplication.clipboard().setText(text) - - @asyncSlot() - async def _on_open_startup_details(self) -> None: - pid, active_pids = self._startup_args - dialog = self._startup_dialog - if dialog is None: - dialog = StartupImpactDialog( - self._ctx.mod_service.startup_impact, self._qml_engine, self - ) - dialog.destroyed.connect(self._on_startup_dialog_destroyed) - self._startup_dialog = dialog - dialog.show() - dialog.raise_() - dialog.activateWindow() - await dialog.panel.set_data(pid, active_pids) - - def _on_startup_dialog_destroyed(self) -> None: - self._startup_dialog = None - - async def _load_preview( - self, mod_path: Path | None, mod_id: str, mod_name: str - ) -> None: - """Load preview image in background thread. Only updates UI if current mod.""" - try: - preview_path = await asyncio.to_thread(_find_preview, mod_path) - - if preview_path is not None: - image = await asyncio.to_thread(QImage, str(preview_path)) - if self._current_mod_id == mod_id and not image.isNull(): - self._banner.setPixmap(QPixmap.fromImage(image)) - self._banner.setShowOverlay(False) - return - - # No usable preview — show fallback with overlay - if self._current_mod_id == mod_id: - w = self._banner.width() or 300 - h = self._banner.heightForWidth(w) - fallback = await asyncio.to_thread(generate_preview, mod_name, w, h) - if self._current_mod_id == mod_id: - self._banner.setShowOverlay(True) - self._banner.setPixmap(QPixmap.fromImage(fallback)) - except asyncio.CancelledError: - pass - - async def set_time_analytics( - self, - pid: str | None, - active_pids: list[str], - ) -> None: - self._startup_token += 1 - token = self._startup_token - self._startup_args = (pid, active_pids) - - sis = self._ctx.mod_service.startup_impact - if not sis or not pid: - self._set_qml("startup", _NO_STARTUP) - return - - report, *_, own = await sis.snapshot(active_pids, pid) - if token != self._startup_token: - return - - if report is None: - self._set_qml( - "startup", - { - "available": False, - "message": "No startup data recorded yet. " - "Launch the game to generate it.", - }, - ) - return - - mod = report.find(pid, None) - if mod is None or own < 0.001: - self._set_qml( - "startup", - { - "available": False, - "message": "This mod wasn't measured in the last recorded launch.", - }, - ) - return - - rank = 1 + sum(1 for m in report.mods if m.total_impact_s > own) - top = sorted(mod.metrics.items(), key=lambda kv: kv[1], reverse=True) - self._set_qml( - "startup", - { - "available": True, - "own": format_duration(own), - "rank": f"#{rank} slowest of {len(report.mods)} mods", - "estimated": pid.lower() not in {a.lower() for a in active_pids}, - "color": impact_color(own), - "rows": [ - { - "label": metric_label(name), - "value": format_duration(value), - "fraction": min(1.0, value / own), - } - for name, value in top[:_TOP_METRICS] - if value >= 0.001 - ], - }, - ) - - def clear(self) -> None: - self._mod = None - self._current_mod_id = None - self._startup_token += 1 - self._placeholder.show() - self._banner.hide() - self._qml.hide() - self._description = "" - self._set_qml("info", None) - self._set_qml("description", "") - self._set_qml("startup", None) - - def _on_desc_toggled(self, expanded: bool) -> None: - self._ui_prefs.desc_expanded = expanded - save_ui_prefs(self._ui_prefs, self._ctx.config_service) diff --git a/src/pxmodrim/ui/panels/mod_list_panel.py b/src/pxmodrim/ui/panels/mod_list_panel.py deleted file mode 100644 index 026b3c5..0000000 --- a/src/pxmodrim/ui/panels/mod_list_panel.py +++ /dev/null @@ -1,351 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import ( - Q_ARG, - Property, - QEvent, - QMetaObject, - QObject, - Qt, - QUrl, - Signal, - Slot, -) -from PySide6.QtGui import QAction, QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import ( - QHBoxLayout, - QLineEdit, - QMenu, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.ui.components.icons import icon -from pxmodrim.ui.components.mod_activation import toggle_mods -from pxmodrim.ui.components.mod_updates import ( - add_update_action, - update_mods, -) -from pxmodrim.ui.models.mod_list_model import ModListModel -from pxmodrim.ui.models.mod_list_proxy_model import ModListProxyModel -from pxmodrim.ui.theme.palette import PALETTE - -if TYPE_CHECKING: - from pxmodrim.core.config import AppConfig -_QML_DIR = Path(__file__).parent -_MOD_LIST_QML = _QML_DIR / "ModList.qml" - -_LIST_VIEW_CONTAIN = 4 - - -class ModListPanel(QWidget): - mod_selected = Signal(str) - active_mods_changed = Signal() - order_changed = Signal() - selection_changed = Signal(list) - compact_mode_changed = Signal(bool) - highlighted_changed = Signal() - - @Property(bool, notify=compact_mode_changed) - def compactMode(self) -> bool: - return self._compact_mode - - @Slot(bool) - def setCompactMode(self, enabled: bool) -> None: - if self._compact_mode != enabled: - self._compact_mode = enabled - self.compact_mode_changed.emit(enabled) - - @Property(int, notify=highlighted_changed) - def highlightRevision(self) -> int: - return self._highlight_revision - - @Slot(str, result=bool) - def isHighlighted(self, uuid: str) -> bool: - return uuid in self._highlighted - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._ctx = ctx - cfg = getattr(ctx, "config", None) - self._compact_mode: bool = ( - getattr(cfg, "compact_mod_list", False) if cfg else False - ) - self._highlighted: frozenset[str] = frozenset() - self._highlight_revision = 0 - self._highlight_generation = 0 - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Search bar - search_container = QWidget(self) - search_container.setObjectName("searchBox") - search_container.setFixedHeight(56) - search_layout = QHBoxLayout(search_container) - search_layout.setContentsMargins(16, 8, 16, 8) - - self.search_input = QLineEdit(search_container) - self.search_input.setObjectName("searchInput") - self.search_input.setPlaceholderText("Search mods, package ID, author…") - self.search_input.setClearButtonEnabled(True) - self.search_input.textChanged.connect(self._on_search_changed) - - # Leading search icon - search_icon = icon("search", 16, PALETTE["TEXT_DIM"]) - search_action = QAction(search_icon, "", self) - self.search_input.addAction( - search_action, QLineEdit.ActionPosition.LeadingPosition - ) - - search_layout.addWidget(self.search_input) - layout.addWidget(search_container) - - # Models: source + proxy - self._model = ModListModel({}, ctx.diagnostics_service, self) - self._proxy = ModListProxyModel(self._model, self) - self._model.active_mods_changed.connect(self.active_mods_changed) - - # QML view - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_1"])) - self._qml.setAcceptDrops(True) - - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("modListPanel", self) - qml_ctx.setContextProperty("modListModel", self._proxy) - qml_ctx.setContextProperty("modListHasFocus", False) - self._qml.setSource(QUrl.fromLocalFile(str(_MOD_LIST_QML))) - - layout.addWidget(self._qml) - - self._qml.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self._qml.installEventFilter(self) - - ctx.active_state_changed.connect(self._on_core_state_changed) - if hasattr(ctx, "config_changed"): - ctx.config_changed.connect(self._on_config_changed) - # Reactive bindings - ctx.mod_service.mods_changed.connect(self._on_mods_changed) - - # ── Public API ──────────────────────────────────────────── - - def load_mods(self, mods: dict[str, ListedMod], active_uuids: list[str]) -> None: - self._model.load_mods(mods, active_uuids) - - def active_uuids(self) -> list[str]: - return self._model.active_uuids() - - @property - def model(self) -> ModListModel: - return self._model - - @property - def proxy_model(self) -> ModListProxyModel: - return self._proxy - - def set_sidebar_filter(self, uuids: set[str] | None) -> None: - self._proxy.set_sidebar_filter(uuids) - - def set_search_filter(self, text: str) -> None: - self._proxy.set_search_filter(text) - - # ── Reactive ────────────────────────────────────────────── - - def _on_mods_changed(self, _: None = None) -> None: - asyncio.ensure_future(self._refresh()) - - async def _refresh(self) -> None: - self._model.update_provider_colors(self._ctx.mod_service.provider_colors) - self._model.load_mods(self._ctx.all_mods, self._ctx.active_uuids) - impacts = await self._ctx.mod_service.startup_impact.get_all_averages() - self._model.set_startup_impact(impacts) - - # ── Slots called from QML ───────────────────────────────── - - @Slot(str) - def modSelected(self, uuid: str) -> None: - self.mod_selected.emit(uuid) - - def select_uuid(self, uuid: str) -> bool: - """Select and scroll to a mod; False if it is hidden by a filter.""" - row = self.rowForUuid(uuid) - root: Any = self._qml.rootObject() - if row < 0 or root is None: - return False - root.revealRow(row, uuid) - return True - - @Slot() - def clearSelection(self) -> None: - root = self._qml.rootObject() - if root is not None: - list_view = root.findChild(QObject, "listView") - if list_view is not None: - list_view.setProperty("currentIndex", -1) - list_view.setProperty("anchorIndex", -1) - list_view.setProperty("selectedIndices", []) - list_view.setProperty("currentUuid", "") - list_view.setProperty("anchorUuid", "") - list_view.setProperty("selectedUuids", []) - self.selection_changed.emit([]) - self.mod_selected.emit("") - - def _proxy_to_source_row(self, proxy_row: int) -> int: - proxy_index = self._proxy.index(proxy_row, 0) - source_index = self._proxy.mapToSource(proxy_index) - return source_index.row() - - @Slot(int, result=str) - def uuidAt(self, row: int) -> str: - source_row = self._proxy_to_source_row(row) - item = self._model.get_item(source_row) - return item.uuid if item else "" - - @Slot(str, result=int) - def rowForUuid(self, uuid: str) -> int: - row = self._proxy.proxy_row_for_uuid(uuid) - return row if row is not None else -1 - - @asyncSlot(int) - async def toggleCheck(self, row: int) -> None: - await self._toggle_rows([row]) - - @asyncSlot(list) - async def toggleChecked(self, rows: list[int]) -> None: - await self._toggle_rows(rows) - - async def _toggle_rows(self, rows: list[int]) -> None: - selected_uuids: list[str] = [] - for proxy_row in rows: - item = self._model.get_item(self._proxy_to_source_row(proxy_row)) - if item is not None: - selected_uuids.append(item.uuid) - if not selected_uuids: - return - - previously_active = set(self._ctx.active_uuids) - await toggle_mods(self._ctx, self, selected_uuids) - active_after = set(self._ctx.active_uuids) - newly_enabled = [ - uuid - for uuid in selected_uuids - if uuid not in previously_active and uuid in active_after - ] - if newly_enabled: - self._highlight_rows(newly_enabled) - - def _highlight_rows(self, uuids: list[str]) -> None: - rows = [ - row - for uuid in uuids - if (row := self._proxy.proxy_row_for_uuid(uuid)) is not None - ] - root = self._qml.rootObject() - if not rows or root is None: - return - list_view = root.findChild(QObject, "listView") - if list_view is not None: - QMetaObject.invokeMethod( - list_view, - "positionViewAtIndex", - Qt.ConnectionType.DirectConnection, - Q_ARG("int", rows[-1]), - Q_ARG("int", _LIST_VIEW_CONTAIN), - ) - self._set_highlighted(uuids) - self._highlight_generation += 1 - asyncio.create_task(self._clear_highlight(self._highlight_generation)) - - def _set_highlighted(self, uuids: list[str]) -> None: - self._highlighted = frozenset(uuids) - self._highlight_revision += 1 - self.highlighted_changed.emit() - - async def _clear_highlight(self, generation: int) -> None: - await asyncio.sleep(1.2) - if generation == self._highlight_generation: - self._set_highlighted([]) - - @Slot("QVariantList") - def showContextMenu(self, uuids: list[str]) -> None: - if not download_manager(self._ctx).available: - return - menu = QMenu(self) - menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - menu.setToolTipsVisible(True) - add_update_action(menu, self._ctx, uuids, lambda: self._update_selected(uuids)) - menu.popup(self.cursor().pos()) - - @asyncSlot() - async def _update_selected(self, uuids: list[str]) -> None: - await update_mods(self._ctx, uuids, self) - - @Slot(int, result=bool) - def isChecked(self, row: int) -> bool: - source_row = self._proxy_to_source_row(row) - item = self._model.get_item(source_row) - return item.checked if item else False - - def _on_core_state_changed(self, active_uuids: object) -> None: - if not isinstance(active_uuids, tuple): - return - current = frozenset(self._model.active_uuids()) - new = frozenset(active_uuids) - self._model.set_checkable(new - current, True) - self._model.set_checkable(current - new, False) - self._model.commitOrder(list(active_uuids)) - - def _on_config_changed(self, cfg: AppConfig) -> None: - if self._compact_mode != cfg.compact_mod_list: - self._compact_mode = cfg.compact_mod_list - self.compact_mode_changed.emit(self._compact_mode) - - @Slot(list) - def commitOrder(self, uuids: list[str]) -> None: - self._model.commitOrder(uuids) - self._ctx.set_active(self._model.active_uuids()) - self.order_changed.emit() - - @Slot(int, int) - def moveRow(self, source_row: int, target_row: int) -> None: - self._proxy.move_row(source_row, target_row, clamp_to_active=True) - - @Slot() - def dragEnded(self) -> None: - self._ctx.set_active(self._model.active_uuids()) - self.order_changed.emit() - - @Slot(list) - def selectionChanged(self, uuids: list[str]) -> None: - self.selection_changed.emit([uuid for uuid in uuids if uuid]) - - # ── Private slots ───────────────────────────────────────── - - def _on_search_changed(self, text: str) -> None: - self._proxy.set_search_filter(text) - - def eventFilter(self, obj: QObject, event: QEvent) -> bool: - if obj is self._qml: - if event.type() == QEvent.Type.FocusIn: - self._qml.rootContext().setContextProperty("modListHasFocus", True) - elif event.type() == QEvent.Type.FocusOut: - self._qml.rootContext().setContextProperty("modListHasFocus", False) - return super().eventFilter(obj, event) diff --git a/src/pxmodrim/ui/panels/restore_snapshot_dialog.py b/src/pxmodrim/ui/panels/restore_snapshot_dialog.py deleted file mode 100644 index cece224..0000000 --- a/src/pxmodrim/ui/panels/restore_snapshot_dialog.py +++ /dev/null @@ -1,169 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime -from pathlib import Path - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QMessageBox, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.mods_config import parse_mods_config -from pxmodrim.ui.components import AppButton - - -class RestoreSnapshotDialog(QDialog): - """Let the user choose a saved mod list to restore.""" - - def __init__(self, snapshots: list[Path], parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("restoreSnapshotDialog") - self.setWindowTitle("Restore Mod List") - self.resize(560, 420) - - self._snapshots = snapshots - self._selected_snapshot: Path | None = None - - layout = QVBoxLayout(self) - - instruction = QLabel( - "Choose a saved mod list. Your current list will be backed up before " - "it is replaced.", - self, - ) - instruction.setWordWrap(True) - layout.addWidget(instruction) - - self._list_widget = QListWidget(self) - self._populate_list() - layout.addWidget(self._list_widget) - - self._details_label = QLabel(self) - self._details_label.setWordWrap(True) - layout.addWidget(self._details_label) - - buttons = QHBoxLayout() - buttons.addStretch() - - self._restore_btn = AppButton("Restore", self) - self._restore_btn.setObjectName("primaryAction") - self._restore_btn.setEnabled(False) - self._restore_btn.clicked.connect(self._on_restore_clicked) - - cancel_btn = AppButton("Cancel", self) - cancel_btn.clicked.connect(self.reject) - - buttons.addWidget(cancel_btn) - buttons.addWidget(self._restore_btn) - layout.addLayout(buttons) - - self._list_widget.currentItemChanged.connect(self._on_item_changed) - self._list_widget.itemDoubleClicked.connect(self._on_item_double_clicked) - - if self._list_widget.count() > 0: - self._list_widget.setCurrentRow(0) - - @property - def selected_snapshot(self) -> Path | None: - return self._selected_snapshot - - def _format_timestamp(self, path: Path) -> str: - stem = path.stem - if stem.startswith("ModsConfig_"): - parts = stem[len("ModsConfig_") :].split("_") - if len(parts) >= 2 and len(parts[0]) == 8 and len(parts[1]) >= 6: - d, t = parts[0], parts[1][:6] - try: - dt = datetime.strptime(f"{d}_{t}", "%Y%m%d_%H%M%S").replace( - tzinfo=UTC - ) - return dt.strftime("%Y-%m-%d %H:%M:%S UTC") - except ValueError: - pass - try: - modified = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC) - return modified.strftime("%Y-%m-%d %H:%M:%S UTC") - except OSError: - return stem - - def _populate_list(self) -> None: - for path in self._snapshots: - item = QListWidgetItem(self._list_widget) - parsed = parse_mods_config(path) - ts = self._format_timestamp(path) - - if parsed is not None: - active_count = len(parsed.activeMods) - noun = "mod" if active_count == 1 else "mods" - text = f"{ts} — {active_count} active {noun}, RimWorld {parsed.version}" - item.setData(Qt.ItemDataRole.UserRole, (path, True, parsed)) - else: - text = f"{ts} — Cannot be read" - item.setData(Qt.ItemDataRole.UserRole, (path, False, None)) - - item.setText(text) - item.setToolTip(path.name) - - def _on_item_changed( - self, current: QListWidgetItem | None, _: QListWidgetItem | None - ) -> None: - if current is None: - self._selected_snapshot = None - self._restore_btn.setEnabled(False) - self._details_label.setText("") - return - - path, is_valid, parsed = current.data(Qt.ItemDataRole.UserRole) - if is_valid and parsed is not None: - self._selected_snapshot = path - self._restore_btn.setEnabled(True) - self._details_label.setText( - f"File: {path.name}
    " - f"Active mods: {len(parsed.activeMods)} | " - f"Game version: {parsed.version}" - ) - else: - self._selected_snapshot = None - self._restore_btn.setEnabled(False) - warn_msg = ( - f"This saved mod list cannot be read. " - f"{path.name} will not be restored." - ) - self._details_label.setText(warn_msg) - - def _on_restore_clicked(self) -> None: - if self._selected_snapshot is not None: - self.accept() - - def _on_item_double_clicked(self, _: QListWidgetItem) -> None: - self._on_restore_clicked() - - -class ConfirmRestoreDialog(QMessageBox): - """Ask for confirmation before replacing the current mod list.""" - - def __init__(self, snapshot_name: str, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Question) - # QMessageBox's setter is a no-op on macOS. - QDialog.setWindowTitle(self, "Restore Mod List") - self.setText(f"Restore the mod list saved in {snapshot_name}?") - self.setInformativeText( - "This replaces your current active mod list. PxModRim will save a " - "backup of the current list first." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - yes_btn = self.button(QMessageBox.StandardButton.Yes) - if yes_btn: - yes_btn.setText("Restore") - yes_btn.setObjectName("primaryAction") diff --git a/src/pxmodrim/ui/panels/settings_panel.py b/src/pxmodrim/ui/panels/settings_panel.py deleted file mode 100644 index 35c58a8..0000000 --- a/src/pxmodrim/ui/panels/settings_panel.py +++ /dev/null @@ -1,267 +0,0 @@ -from __future__ import annotations - -import dataclasses -from collections.abc import Sequence -from pathlib import Path -from typing import Any - -import msgspec -from PySide6.QtCore import Property, QObject, Qt, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QDialog, QFileDialog, QVBoxLayout, QWidget -from qasync import asyncSlot - -from pxmodrim.core.config import ( - AppConfig, - community_rules_file, - detect_game_paths, -) -from pxmodrim.core.constants import AfterLaunch -from pxmodrim.core.context import CoreContext -from pxmodrim.core.loading import LoadingState -from pxmodrim.core.sort.community_service import CommunityRulesService -from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.progress_dialog import ProgressDialog -from pxmodrim.ui.settings_section import SettingsSection, SettingsSectionFactory -from pxmodrim.ui.theme.palette import PALETTE - -_QML = Path(__file__).parent / "Settings.qml" - -_FOLDER_TITLES = { - "game": "Select RimWorld Game Folder", - "local": "Select Local Mods Folder", - "workshop": "Select Workshop Mods Folder", - "config": "Select RimWorld Config Folder", -} - - -class _FolderDialog(QFileDialog): - def __init__(self, parent: QWidget, title: str) -> None: - super().__init__(parent, title) - self.setFileMode(QFileDialog.FileMode.Directory) - self.setOption(QFileDialog.Option.ShowDirsOnly) - - -class _SettingsBackend(QObject): - """State and actions behind Settings.qml; the dialog owns accept/reject.""" - - pathPicked = Signal(str, str) - saveRequested = Signal(dict) - cancelRequested = Signal() - communityChanged = Signal() - cacheChanged = Signal() - - def __init__( - self, ctx: CoreContext, dialog: QDialog, sections: Sequence[SettingsSection] - ) -> None: - super().__init__(dialog) - self._ctx = ctx - self._dialog = dialog - self._sections = [ - { - "title": section.title, - "source": QUrl.fromLocalFile(str(section.source)).toString(), - "section": section, - } - for section in sections - ] - cfg = ctx.config - self._initial: dict[str, Any] = { - "game": cfg.paths.game, - "local": cfg.paths.local, - "workshop": cfg.paths.workshop, - "config": cfg.paths.config_folder, - "compact": cfg.compact_mod_list, - "launchArgs": cfg.launch_args, - "launchWrapper": cfg.launch_wrapper, - "afterLaunch": int(cfg.after_launch), - "confirmErrors": cfg.launch_confirm_errors, - "confirmUnsaved": cfg.launch_confirm_unsaved, - "confirmRunning": cfg.launch_confirm_running, - "useAltIds": cfg.sort.use_alternative_package_ids, - "checkMissing": cfg.sort.check_missing_dependencies, - "useCommunity": cfg.sort.use_community_rules, - } - rules = community_rules_file() - self._community_status = ( - f"Found: {rules}" if rules.exists() else "Not downloaded" - ) - self._community_busy = False - self._cache_available = bool(cfg.paths.config_folder) - self._cache_status = ( - "Cache ready" - if self._cache_available - else "No config folder — not available" - ) - self._cache_busy = False - - @Property(dict, constant=True) # type: ignore[arg-type] - def initial(self) -> dict[str, Any]: - return self._initial - - @Property(list, constant=True) # type: ignore[arg-type] - def sections(self) -> list[dict[str, Any]]: - return self._sections - - @Property(str, notify=communityChanged) # type: ignore[arg-type] - def communityStatus(self) -> str: - return self._community_status - - @Property(bool, notify=communityChanged) # type: ignore[arg-type] - def communityBusy(self) -> bool: - return self._community_busy - - @Property(str, notify=cacheChanged) # type: ignore[arg-type] - def cacheStatus(self) -> str: - return self._cache_status - - @Property(bool, notify=cacheChanged) # type: ignore[arg-type] - def cacheAvailable(self) -> bool: - return self._cache_available - - @Property(bool, notify=cacheChanged) # type: ignore[arg-type] - def cacheBusy(self) -> bool: - return self._cache_busy - - def _set_community(self, status: str, busy: bool) -> None: - self._community_status = status - self._community_busy = busy - self.communityChanged.emit() - - def _set_cache(self, status: str, busy: bool) -> None: - self._cache_status = status - self._cache_busy = busy - self.cacheChanged.emit() - - @asyncSlot(str) - async def browse(self, key: str) -> None: - title = _FOLDER_TITLES.get(key) - if title is None: - return - result, dialog = await await_dialog(_FolderDialog, self._dialog, title) - files = dialog.selectedFiles() - if result == QDialog.DialogCode.Accepted and files: - self.pathPicked.emit(key, files[0]) - - @Slot(str, result=str) - def localCandidate(self, game_path: str) -> str: - candidate = Path(game_path) / "Mods" - return str(candidate) if candidate.is_dir() else "" - - @Slot() - def autoDetect(self) -> None: - detected = detect_game_paths() - for key, value in ( - ("game", detected.game), - ("local", detected.local), - ("workshop", detected.workshop), - ("config", detected.config_folder), - ): - if value: - self.pathPicked.emit(key, value) - - @asyncSlot() - async def downloadCommunityRules(self) -> None: - self._set_community("Downloading…", True) - status = "Download failed" - try: - async with ProgressDialog(LoadingState(self), self._dialog) as dialog: - service = CommunityRulesService(self._ctx.config_service) - path = await service.ensure_rules(dialog.loading, force=True) - QTimer.singleShot(1000, dialog, dialog.deleteLater) - if path: - status = f"Downloaded: {path}" - finally: - self._set_community(status, False) - - @asyncSlot() - async def clearCache(self) -> None: - self._set_cache("Clearing…", True) - status = "Cache cleared" - try: - await self._ctx.mod_service.startup_impact.clear() - except OSError as exc: - status = f"Error: {exc}" - finally: - self._set_cache(status, False) - - @Slot("QVariantMap") - def save(self, values: dict[str, Any]) -> None: - self.saveRequested.emit(values) - - @Slot() - def cancel(self) -> None: - self.cancelRequested.emit() - - -class SettingsPanel(QDialog): - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine, - parent: QWidget, - sections: Sequence[SettingsSectionFactory] = (), - ) -> None: - super().__init__(parent) - self.setObjectName("settingsPanel") - self.setWindowTitle("Settings") - self.setModal(True) - self.resize(700, 560) - - self._ctx = ctx - self._config = ctx.config - - self._sections = [factory(self) for factory in sections] - self._backend = _SettingsBackend(ctx, self, self._sections) - self._backend.saveRequested.connect(self._save) - self._backend.cancelRequested.connect(self.reject) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self._qml.rootContext().setContextProperty("settings", self._backend) - self._qml.setSource(QUrl.fromLocalFile(str(_QML))) - layout.addWidget(self._qml) - # Unload before the backend and sections (children of this dialog) are - # destroyed, or every binding to them re-evaluates against null. Queued: - # Save/Cancel finish the dialog from inside a QML click handler. - self.finished.connect(self._unload, Qt.ConnectionType.QueuedConnection) - - def _unload(self) -> None: - self._qml.setSource(QUrl()) - - def _save(self, values: dict[str, Any]) -> None: - self._config = msgspec.structs.replace( - self._config, - paths=msgspec.structs.replace( - self._config.paths, - game=values["game"].strip(), - local=values["local"].strip(), - workshop=values["workshop"].strip(), - config_folder=values["config"].strip(), - ), - sort=dataclasses.replace( - self._config.sort, - use_alternative_package_ids=bool(values["useAltIds"]), - check_missing_dependencies=bool(values["checkMissing"]), - use_community_rules=bool(values["useCommunity"]), - ), - compact_mod_list=bool(values["compact"]), - launch_args=values["launchArgs"].strip(), - launch_wrapper=values["launchWrapper"].strip(), - after_launch=AfterLaunch(int(values["afterLaunch"])), - launch_confirm_errors=bool(values["confirmErrors"]), - launch_confirm_unsaved=bool(values["confirmUnsaved"]), - launch_confirm_running=bool(values["confirmRunning"]), - ) - for section in self._sections: - section.apply() - self.accept() - - def get_config(self) -> AppConfig: - return self._config diff --git a/src/pxmodrim/ui/panels/sidebar_panel.py b/src/pxmodrim/ui/panels/sidebar_panel.py deleted file mode 100644 index 1eda5b3..0000000 --- a/src/pxmodrim/ui/panels/sidebar_panel.py +++ /dev/null @@ -1,58 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QObject, Signal -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QWidget - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.view.sidebar import AllModsEntry, SidebarEntry -from pxmodrim.ui.components.filter_sidebar import FilterSidebar -from pxmodrim.ui.models.sidebar_model import SidebarModel - - -class SidebarPanel(FilterSidebar): - entry_selected = Signal(object) # SidebarEntry - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - self._model = SidebarModel() - super().__init__(qml_engine, self._model, parent) - self._model.setParent(self) - self._entries: list[SidebarEntry] = [] - self.activated.connect(self._on_activated) - ctx.diagnostics_service.sidebar_entries_changed.connect(self.set_entries) - - def set_entries(self, entries: list[SidebarEntry]) -> None: - reset = len(entries) != len(self._entries) - self._entries = entries - self._model.update_entries(entries) - if reset: - current = self.current_entry() - if current is not None: - self.entry_selected.emit(current) - - def current_entry(self) -> SidebarEntry | None: - selected = self.current_index() - if 0 <= selected < len(self._entries): - return self._entries[selected] - return None - - def select_all_entry(self) -> None: - """Highlight and activate the unfiltered "All" entry.""" - row = next( - (i for i, e in enumerate(self._entries) if isinstance(e, AllModsEntry)), -1 - ) - root = self.rootObject() - view = root.findChild(QObject, "listView") if root is not None else None - if row < 0 or view is None: - return - view.setProperty("currentIndex", row) - self._on_activated(row) - - def _on_activated(self, row: int) -> None: - if 0 <= row < len(self._entries): - self.entry_selected.emit(self._entries[row]) diff --git a/src/pxmodrim/ui/panels/startup_impact_data.py b/src/pxmodrim/ui/panels/startup_impact_data.py deleted file mode 100644 index bd6b107..0000000 --- a/src/pxmodrim/ui/panels/startup_impact_data.py +++ /dev/null @@ -1,225 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from datetime import datetime -from typing import Any - -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, - normalize_package_id, -) -from pxmodrim.core.services.startup_impact_service.phases import ( - PHASE_LABELS, - Phase, - metric_phase, - phase_totals, -) -from pxmodrim.ui.models.impact import format_duration, impact_color -from pxmodrim.ui.theme.palette import PALETTE - -PHASE_COLORS: dict[Phase, str] = { - Phase.CONTENT: "#b57bee", - Phase.MOD_CONSTRUCTORS: "#f06ba8", - Phase.STATIC_CONSTRUCTORS: "#43b581", - Phase.PATCHES: "#f0883e", - Phase.DEFS: "#4fb3e8", - Phase.DEFERRED: "#e8c547", - Phase.OTHER: PALETTE["TEXT_DIM"], -} - -BASE_GAME_LABEL = "Base game" -_MIN_S = 0.001 -_TOOLTIP_METRICS = 8 - - -def build_startup_impact( - report: StartupImpactReport, - *, - active_pids: list[str], - selected_pid: str | None, - estimated_s: float, -) -> dict[str, Any]: - active = {normalize_package_id(p) for p in active_pids} - selected = normalize_package_id(selected_pid) if selected_pid else None - base = next((m for m in report.mods if m.package_id == BASE_GAME_PID), None) - mods = sorted( - (m for m in report.mods if m.package_id != BASE_GAME_PID), - key=lambda m: m.total_impact_s, - reverse=True, - ) - - base_s = base.total_impact_s if base else 0.0 - mods_s = sum(m.total_impact_s for m in mods) - off_s = sum(m.off_thread_total_impact_s for m in report.mods) - bar_total = base_s + mods_s + off_s - max_mod_s = mods[0].total_impact_s if mods else 0.0 - - rows = [ - _mod_row(m, rank, max_mod_s, active, selected) - for rank, m in enumerate(mods, start=1) - ] - - entries = [(m.mod_name, _key(m), False, m.metrics) for m in mods] - base_entry = (BASE_GAME_LABEL, BASE_GAME_PID, True, base.metrics if base else {}) - - return { - "timestamp": _format_timestamp(report.timestamp), - "last_launch": format_duration(report.loading_time_s or base_s + mods_s), - "estimated": format_duration(estimated_s) if active_pids else "", - "summary": [ - { - "label": label, - "value": format_duration(seconds), - "fraction": seconds / bar_total if bar_total > 0 else 0.0, - "color": color, - "parallel": parallel, - } - for label, seconds, color, parallel in ( - (BASE_GAME_LABEL, base_s, PALETTE["TEXT_DIM"], False), - ("Mods, main thread", mods_s, PALETTE["PRIMARY"], False), - ("Off-thread (parallel)", off_s, PALETTE["PRIMARY"], True), - ) - if seconds >= _MIN_S - ], - "base_value": format_duration(base_s), - "mods_value": format_duration(mods_s), - "off_value": format_duration(off_s), - "legend": [{"label": PHASE_LABELS[p], "color": PHASE_COLORS[p]} for p in Phase], - "mods": rows, - "active_count": sum(1 for r in rows if r["active"]), - "selected_key": next((r["key"] for r in rows if r["selected"]), ""), - "phases_with_base": _phase_rows([base_entry, *entries], selected), - "phases_mods_only": _phase_rows(entries, selected), - } - - -def _key(mod: StartupImpactMod) -> str: - return mod.package_id or mod.mod_name - - -def _mod_row( - mod: StartupImpactMod, - rank: int, - max_mod_s: float, - active: set[str], - selected: str | None, -) -> dict[str, Any]: - totals = phase_totals(mod.metrics) - by_phase = _metrics_by_phase(mod.metrics) - ranked = sorted( - ((p, s) for p, s in totals.items() if s >= _MIN_S), - key=lambda ps: ps[1], - reverse=True, - ) - top_phase_s = ranked[0][1] if ranked else 0.0 - pid = mod.package_id - return { - "key": _key(mod), - "name": mod.mod_name, - "rank": rank, - "seconds": mod.total_impact_s, - "time": format_duration(mod.total_impact_s), - "color": impact_color(mod.total_impact_s), - "active": pid is not None and pid in active, - "selected": pid is not None and pid == selected, - "off_thread": format_duration(mod.off_thread_total_impact_s) - if mod.off_thread_total_impact_s >= _MIN_S - else "", - "dominant": PHASE_LABELS[ranked[0][0]] if ranked else "", - "segments": [ - { - "color": PHASE_COLORS[p], - "fraction": totals[p] / max_mod_s if max_mod_s > 0 else 0.0, - } - for p in Phase - if totals[p] >= _MIN_S - ], - "phases": [ - { - "label": PHASE_LABELS[p], - "color": PHASE_COLORS[p], - "value": format_duration(s), - "fraction": s / top_phase_s, - "tooltip": _tooltip(by_phase[p]), - } - for p, s in ranked - ], - } - - -def _phase_rows( - entries: list[tuple[str, str, bool, dict[str, float]]], - selected: str | None, -) -> list[dict[str, Any]]: - per_phase: dict[Phase, list[tuple[float, str, str, bool]]] = defaultdict(list) - metric_sums: dict[Phase, dict[str, float]] = defaultdict(lambda: defaultdict(float)) - for name, key, is_base, metrics in entries: - for phase, seconds in phase_totals(metrics).items(): - if seconds >= _MIN_S: - per_phase[phase].append((seconds, name, key, is_base)) - for metric, seconds in metrics.items(): - label = metric_label(metric.split("|", 1)[0]) - metric_sums[metric_phase(metric)][label] += seconds - - grand = sum(s for items in per_phase.values() for s, *_ in items) - totals = {p: sum(s for s, *_ in items) for p, items in per_phase.items()} - top_phase_s = max(totals.values(), default=0.0) - rows = [] - for phase, seconds in sorted(totals.items(), key=lambda ps: ps[1], reverse=True): - contributors = sorted(per_phase[phase], reverse=True) - top_s = contributors[0][0] - rows.append( - { - "label": PHASE_LABELS[phase], - "color": PHASE_COLORS[phase], - "seconds": seconds, - "value": format_duration(seconds), - "share": f"{round(100 * seconds / grand)}%" if grand > 0 else "", - "fraction": seconds / top_phase_s, - "tooltip": _tooltip(metric_sums[phase]), - "mods": [ - { - "name": name, - "key": key, - "value": format_duration(s), - "fraction": s / top_s, - "base": is_base, - "selected": selected is not None and key == selected, - } - for s, name, key, is_base in contributors - ], - } - ) - return rows - - -def _metrics_by_phase(metrics: dict[str, float]) -> dict[Phase, dict[str, float]]: - grouped: dict[Phase, dict[str, float]] = defaultdict(lambda: defaultdict(float)) - for key, seconds in metrics.items(): - grouped[metric_phase(key)][metric_label(key)] += seconds - return grouped - - -def _tooltip(metrics: dict[str, float]) -> str: - top = sorted(metrics.items(), key=lambda kv: kv[1], reverse=True) - lines = [ - f"{label}: {format_duration(s)}" - for label, s in top[:_TOOLTIP_METRICS] - if s >= _MIN_S - ] - if len(top) > _TOOLTIP_METRICS: - lines.append(f"+ {len(top) - _TOOLTIP_METRICS} more") - return "\n".join(lines) - - -def _format_timestamp(raw: str) -> str: - if not raw: - return "" - try: - parsed = datetime.fromisoformat(raw) - except ValueError: - return raw - return parsed.astimezone().strftime("%Y-%m-%d %H:%M") diff --git a/src/pxmodrim/ui/panels/time_analytics_panel.py b/src/pxmodrim/ui/panels/time_analytics_panel.py deleted file mode 100644 index 174023e..0000000 --- a/src/pxmodrim/ui/panels/time_analytics_panel.py +++ /dev/null @@ -1,94 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QDialog, QVBoxLayout, QWidget -from shiboken6 import isValid - -from pxmodrim.core.services.startup_impact_service import StartupImpactService -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.panels.startup_impact_data import build_startup_impact -from pxmodrim.ui.theme.palette import PALETTE - -_TIMING_QML = Path(__file__).parent / "TimeAnalytics.qml" - - -class TimeAnalyticsPanel(QWidget): - def __init__( - self, - sis: StartupImpactService | None = None, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self._sis = sis - self._request_token = 0 - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_1"])) - self._qml.setSource(QUrl.fromLocalFile(str(_TIMING_QML))) - layout.addWidget(self._qml) - - async def set_data(self, pid: str | None, active_pids: list[str]) -> None: - self._request_token += 1 - request_token = self._request_token - root_obj = self._qml.rootObject() - if not root_obj: - return - - sis = self._sis - if not sis: - root_obj.setProperty("sourceData", None) - return - - report, base, totals, _ = await sis.snapshot(active_pids) - if request_token != self._request_token or not isValid(root_obj): - return - if not report: - root_obj.setProperty("sourceData", None) - return - - estimated = base + sum(on + off for on, off in totals.values()) - root_obj.setProperty( - "sourceData", - build_startup_impact( - report, - active_pids=active_pids, - selected_pid=pid, - estimated_s=estimated, - ), - ) - - def clear(self) -> None: - self._request_token += 1 - root_obj = self._qml.rootObject() - if root_obj: - root_obj.setProperty("sourceData", None) - - -class StartupImpactDialog(QDialog): - """Non-modal window showing the full startup impact breakdown.""" - - def __init__( - self, - sis: StartupImpactService | None, - qml_engine: QQmlEngine | None, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Startup impact") - self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) - self.resize(900, 720) - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.panel = TimeAnalyticsPanel(sis, qml_engine, self) - layout.addWidget(self.panel) - install_dialog_chrome(self) diff --git a/src/pxmodrim/ui/panels/update_dialog.py b/src/pxmodrim/ui/panels/update_dialog.py deleted file mode 100644 index a7ff88e..0000000 --- a/src/pxmodrim/ui/panels/update_dialog.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtCore import QUrl -from PySide6.QtGui import QDesktopServices -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QTextBrowser, - QVBoxLayout, - QWidget, -) - -from pxmodrim.ui.components.button import AppButton - -if TYPE_CHECKING: - from pxmodrim.core.services.update_service import ReleaseInfo - -SKIP_RESULT = 2 - -_AUTO_UPDATE_NOTE = ( - "Automatic updating may be added later. For now, downloading the release " - "yourself is the safest way to avoid update bugs." -) - -_WEB_SCHEMES = frozenset({"http", "https"}) - - -def _open_web_link(url: QUrl) -> None: - if url.scheme().lower() in _WEB_SCHEMES: - QDesktopServices.openUrl(url) - - -class UpdateDialog(QDialog): - """Offers a newer release. Accepted = open release page, SKIP_RESULT = skip it.""" - - def __init__( - self, - release: ReleaseInfo, - current_version: str, - parent: QWidget | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Update Available") - self.resize(560, 460) - layout = QVBoxLayout(self) - - title = QLabel(f"PxModRim {release.tag} is available", self) - title.setStyleSheet("font-size: 16px; font-weight: 600;") - layout.addWidget(title) - layout.addWidget(QLabel(f"You are running {current_version}.", self)) - - notes = QTextBrowser(self) - notes.setOpenLinks(False) - notes.setOpenExternalLinks(False) - notes.anchorClicked.connect(_open_web_link) - notes.setMarkdown(release.notes or "No release notes.") - layout.addWidget(notes, 1) - - note = QLabel(_AUTO_UPDATE_NOTE, self) - note.setWordWrap(True) - layout.addWidget(note) - - buttons = QHBoxLayout() - skip_btn = AppButton("Skip This Version", self) - skip_btn.clicked.connect(lambda: self.done(SKIP_RESULT)) - later_btn = AppButton("Later", self) - later_btn.clicked.connect(self.reject) - download_btn = AppButton("Download", self) - download_btn.setObjectName("primaryAction") - download_btn.setDefault(True) - download_btn.clicked.connect(self.accept) - buttons.addWidget(skip_btn) - buttons.addStretch() - buttons.addWidget(later_btn) - buttons.addWidget(download_btn) - layout.addLayout(buttons) diff --git a/src/pxmodrim/ui/panels/upload_report_dialog.py b/src/pxmodrim/ui/panels/upload_report_dialog.py deleted file mode 100644 index 02d2c27..0000000 --- a/src/pxmodrim/ui/panels/upload_report_dialog.py +++ /dev/null @@ -1,201 +0,0 @@ -from __future__ import annotations - -import asyncio -from enum import IntEnum -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import ( - QDialog, - QFileDialog, - QHBoxLayout, - QLabel, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.support.report import ( - PasteUploader, - ReportUploadError, - build_report, -) -from pxmodrim.ui.components.button import AppButton -from pxmodrim.ui.components.dialogs import await_dialog - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.components.toast import ToastManager - -_DEFAULT_ENDPOINT = "https://paste.rs/" - - -class UploadChoice(IntEnum): - CANCEL = int(QDialog.DialogCode.Rejected) - UPLOAD = int(QDialog.DialogCode.Accepted) - SAVE_TO_FILE = 2 - - -class UploadConfirmDialog(QDialog): - """Confirmation dialog explaining what will be uploaded and anonymized. - - Finishes with an UploadChoice value. - """ - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("uploadConfirmDialog") - self.setWindowTitle("Upload Log & System Info") - self.setModal(True) - self.resize(520, 320) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(16) - - description_text = ( - "This action generates and uploads an anonymized diagnostic report " - "to help diagnose issues.\n\n" - "The report includes:\n" - " • Recent application log contents (last ~2 MB)\n" - " • System details (OS, architecture, Python, Qt, PySide6)\n" - " • RimWorld version and configured directory paths\n" - " • Mod collection count and active mod list in load order\n\n" - "Privacy & security:\n" - " • Your home directory path is replaced with '~'\n" - " • Your username in other user-directory paths is replaced " - "with ''\n\n" - "Notice: The uploaded report will be publicly viewable " - "by anyone with the link." - ) - desc_label = QLabel(description_text, self) - desc_label.setWordWrap(True) - layout.addWidget(desc_label) - - layout.addStretch() - - button_row = QHBoxLayout() - button_row.setSpacing(10) - - self._save_file_btn = AppButton("Save to file instead…", self) - self._save_file_btn.clicked.connect( - lambda: self.done(UploadChoice.SAVE_TO_FILE) - ) - button_row.addWidget(self._save_file_btn) - - button_row.addStretch() - - cancel_btn = AppButton("Cancel", self) - cancel_btn.clicked.connect(self.reject) - button_row.addWidget(cancel_btn) - - self._upload_btn = AppButton("Upload", self) - self._upload_btn.setObjectName("primaryAction") - self._upload_btn.clicked.connect(self.accept) - button_row.addWidget(self._upload_btn) - - layout.addLayout(button_row) - - -class UploadFailedDialog(QDialog): - """Error dialog shown on upload failure; Accepted means save to a file.""" - - def __init__(self, error_message: str, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setObjectName("uploadFailedDialog") - self.setWindowTitle("Upload Failed") - self.setModal(True) - self.resize(460, 220) - - layout = QVBoxLayout(self) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(16) - - title_label = QLabel("Couldn't upload the report", self) - title_label.setObjectName("dialogTitle") - layout.addWidget(title_label) - - msg_label = QLabel( - f"An error occurred while uploading:\n\n{error_message}\n\n" - "Would you like to save the report to a file instead?", - self, - ) - msg_label.setWordWrap(True) - layout.addWidget(msg_label) - - layout.addStretch() - - button_row = QHBoxLayout() - button_row.addStretch() - - close_btn = AppButton("Close", self) - close_btn.clicked.connect(self.reject) - button_row.addWidget(close_btn) - - save_btn = AppButton("Save to file…", self) - save_btn.setObjectName("primaryAction") - save_btn.clicked.connect(self.accept) - button_row.addWidget(save_btn) - - layout.addLayout(button_row) - - -class _SaveReportFileDialog(QFileDialog): - def __init__(self, parent: QWidget) -> None: - super().__init__( - parent, - "Save Log & System Info", - "pxmodrim-report.txt", - "Text Files (*.txt);;All Files (*)", - ) - self.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) - - -async def _save_report_via_file_dialog( - parent: QWidget, report: str, toast: ToastManager -) -> None: - """Prompt user for a destination file and write the report.""" - result, dialog = await await_dialog(_SaveReportFileDialog, parent) - files = dialog.selectedFiles() - if result != QDialog.DialogCode.Accepted or not files: - return - - path = Path(files[0]) - try: - await asyncio.to_thread(path.write_text, report, "utf-8") - except OSError as exc: - toast.error(f"Failed to save file: {exc}", 5000) - return - toast.success(f"Report saved to {path.name}", 3000) - - -async def handle_upload_report( - parent: QWidget, ctx: CoreContext | Any, toast: ToastManager -) -> str | None: - """Confirm, then upload or save the report; return the uploaded URL if any.""" - choice, _ = await await_dialog(UploadConfirmDialog, parent) - if choice == UploadChoice.CANCEL: - return None - - report = await asyncio.to_thread(build_report, ctx) - - if choice == UploadChoice.SAVE_TO_FILE: - await _save_report_via_file_dialog(parent, report, toast) - return None - - toast.info("Uploading log & system info…", 2000) - cfg = getattr(ctx, "config", None) - endpoint = getattr(cfg, "log_upload_endpoint", "") or _DEFAULT_ENDPOINT - try: - url = await PasteUploader(endpoint=endpoint).async_upload(report) - except (ReportUploadError, OSError, RuntimeError) as exc: - failed_result, _ = await await_dialog(UploadFailedDialog, str(exc), parent) - if failed_result == QDialog.DialogCode.Accepted: - await _save_report_via_file_dialog(parent, report, toast) - return None - - clipboard = QGuiApplication.clipboard() - if clipboard is not None: - clipboard.setText(url) - toast.success(f"Link copied: {url}", 5000) - return url diff --git a/src/pxmodrim/ui/plugins/__init__.py b/src/pxmodrim/ui/plugins/__init__.py deleted file mode 100644 index ab1c8e0..0000000 --- a/src/pxmodrim/ui/plugins/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""UI-layer plugins for PxModRim.""" - -from __future__ import annotations diff --git a/src/pxmodrim/ui/plugins/downloads/__init__.py b/src/pxmodrim/ui/plugins/downloads/__init__.py deleted file mode 100644 index 808d2e2..0000000 --- a/src/pxmodrim/ui/plugins/downloads/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.downloads.plugin import DownloadsUiPlugin - -__all__ = ["DownloadsUiPlugin"] diff --git a/src/pxmodrim/ui/plugins/downloads/model.py b/src/pxmodrim/ui/plugins/downloads/model.py deleted file mode 100644 index 6483733..0000000 --- a/src/pxmodrim/ui/plugins/downloads/model.py +++ /dev/null @@ -1,310 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QPersistentModelIndex, - Qt, - QTimer, - Signal, - Slot, -) - -if TYPE_CHECKING: - from pxmodrim.core.downloads import ( - DownloadItemStatus, - DownloadItemTitle, - DownloadResult, - ) - -_ACTIVE = frozenset({"queued", "checking", "downloading"}) -_FLUSH_MS = 150 -_SPEED_TICK_MS = 1000 -HISTORY_LEN = 90 - - -@dataclass(slots=True) -class _Row: - pid: str - title: str - state: str = "queued" - progress: float = 0.0 - error: str = "" - bytes_done: int = 0 - - -def _final_state(bytes_total: int) -> str: - # pxsteamdl reports 0/0 for an item that is already current. - return "updated" if bytes_total > 0 else "unchanged" - - -class DownloadsModel(QAbstractListModel): - """Per-mod state of the current or last download batch.""" - - summary_changed = Signal() - speed_changed = Signal() - phase_changed = Signal() - - _IdRole = Qt.ItemDataRole.UserRole + 1 - _TitleRole = Qt.ItemDataRole.UserRole + 2 - _StateRole = Qt.ItemDataRole.UserRole + 3 - _ProgressRole = Qt.ItemDataRole.UserRole + 4 - _ErrorRole = Qt.ItemDataRole.UserRole + 5 - - def __init__(self, parent: Any = None) -> None: - super().__init__(parent) - self._rows: list[_Row] = [] - self._by_id: dict[str, _Row] = {} - self._visible: list[_Row] = [] - self._filter = "all" - self._busy = False - self._counts: dict[str, int] = {} - self._dirty: set[str] = set() - self._timer = QTimer(self) - self._timer.setSingleShot(True) - self._timer.setInterval(_FLUSH_MS) - self._timer.timeout.connect(self._flush) - self._phase = "" - self._bytes = 0 - self._sampled_bytes = 0 - self._speed = 0.0 - self._history: list[float] = [] - self._speed_timer = QTimer(self) - self._speed_timer.setInterval(_SPEED_TICK_MS) - self._speed_timer.timeout.connect(self._sample_speed) - - # ── batch lifecycle ─────────────────────────────── - - def begin(self, ids: list[str], titles: dict[str, str]) -> None: - self._timer.stop() - self._dirty.clear() - self._reset_speed() - self.set_phase("") - self.beginResetModel() - self._rows = [_Row(pid, titles.get(pid, pid)) for pid in ids] - self._by_id = {row.pid: row for row in self._rows} - self._recount() - self._visible = self._select() - self.endResetModel() - self.summary_changed.emit() - - def apply(self, status: DownloadItemStatus) -> None: - row = self._by_id.get(status.mod_id) - if row is None: - return - done = status.bytes_done if status.status != "error" else row.bytes_done - self._bytes += done - row.bytes_done - row.bytes_done = done - if status.status == "error": - row.state, row.error, row.progress = "failed", status.error, 0.0 - elif status.status == "success": - row.state, row.progress = _final_state(status.bytes_total), 1.0 - elif status.bytes_total > 0 and status.bytes_done < status.bytes_total: - row.state = "downloading" - row.progress = status.bytes_done / status.bytes_total - else: - row.state, row.progress = _final_state(status.bytes_total), 1.0 - self._mark(row) - - def set_title(self, item: DownloadItemTitle) -> None: - row = self._by_id.get(item.mod_id) - if row is None: - return - if row.title == row.pid: - row.title = item.title - if row.state == "queued": - row.state = "checking" - self._mark(row) - - def set_phase(self, phase: str) -> None: - if phase != self._phase: - self._phase = phase - self.phase_changed.emit() - - def finish(self, result: DownloadResult) -> None: - for row in self._rows: - if row.state in _ACTIVE: - row.state, row.progress = "cancelled", 0.0 - self._dirty.add(row.pid) - self._timer.stop() - self._flush() - - def set_busy(self, busy: bool) -> None: - if self._busy != busy: - self._busy = busy - if busy: - self._sampled_bytes = self._bytes - self._speed_timer.start() - else: - self._speed_timer.stop() - self._speed = 0.0 - self.speed_changed.emit() - self.summary_changed.emit() - - def failed_ids(self) -> list[str]: - return [row.pid for row in self._rows if row.state in {"failed", "cancelled"}] - - @Slot() - def clear(self) -> None: - if not self._busy: - self.begin([], {}) - - @Slot(str) - def setFilter(self, name: str) -> None: - if name != self._filter: - self._filter = name - self.beginResetModel() - self._visible = self._select() - self.endResetModel() - self.summary_changed.emit() - - # ── internals ───────────────────────────────────── - - def _reset_speed(self) -> None: - self._bytes = self._sampled_bytes = 0 - self._speed = 0.0 - self._history = [] - self.speed_changed.emit() - - def _sample_speed(self) -> None: - delta = self._bytes - self._sampled_bytes - self._sampled_bytes = self._bytes - self._speed = delta * 1000 / _SPEED_TICK_MS - self._history = [*self._history[-(HISTORY_LEN - 1) :], self._speed] - self.speed_changed.emit() - - def _mark(self, row: _Row) -> None: - self._dirty.add(row.pid) - if not self._timer.isActive(): - self._timer.start() - - def _matches(self, row: _Row) -> bool: - return { - "all": True, - "active": row.state in _ACTIVE, - "updated": row.state == "updated", - "failed": row.state in {"failed", "cancelled"}, - }.get(self._filter, True) - - def _select(self) -> list[_Row]: - return [row for row in self._rows if self._matches(row)] - - def _recount(self) -> None: - counts: dict[str, int] = {} - for row in self._rows: - counts[row.state] = counts.get(row.state, 0) + 1 - self._counts = counts - - def _flush(self) -> None: - if not self._dirty: - return - dirty, self._dirty = self._dirty, set() - self._recount() - if self._filter == "all": - for pos, row in enumerate(self._visible): - if row.pid in dirty: - idx = self.index(pos) - self.dataChanged.emit(idx, idx) - else: - self.beginResetModel() - self._visible = self._select() - self.endResetModel() - self.summary_changed.emit() - - # ── QAbstractListModel ──────────────────────────── - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - return len(self._visible) if parent is None or not parent.isValid() else 0 - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() >= len(self._visible): - return None - row = self._visible[index.row()] - if role == self._IdRole: - return row.pid - if role == self._TitleRole: - return row.title - if role == self._StateRole: - return row.state - if role == self._ProgressRole: - return row.progress - if role == self._ErrorRole: - return row.error - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - self._IdRole: QByteArray(b"id"), - self._TitleRole: QByteArray(b"title"), - self._StateRole: QByteArray(b"state"), - self._ProgressRole: QByteArray(b"progress"), - self._ErrorRole: QByteArray(b"error"), - } - - # ── QML summary ─────────────────────────────────── - - def _count(self, *states: str) -> int: - return sum(self._counts.get(s, 0) for s in states) - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def total(self) -> int: - return len(self._rows) - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def finished(self) -> int: - return self._count("updated", "unchanged", "failed", "cancelled") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def updated(self) -> int: - return self._count("updated") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def unchanged(self) -> int: - return self._count("unchanged") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def failed(self) -> int: - return self._count("failed", "cancelled") - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def active(self) -> int: - return self._count("queued", "checking", "downloading") - - @Property(str, notify=phase_changed) # type: ignore[arg-type] - def phase(self) -> str: - return self._phase - - @Property(int, notify=summary_changed) # type: ignore[arg-type] - def resolved(self) -> int: - return len(self._rows) - self._count("queued") - - @Property(float, notify=speed_changed) # type: ignore[arg-type] - def speed(self) -> float: - return self._speed - - @Property(float, notify=speed_changed) # type: ignore[arg-type] - def bytesDone(self) -> float: - return float(self._bytes) - - @Property(list, notify=speed_changed) # type: ignore[arg-type] - def speedHistory(self) -> list[float]: - return self._history - - @Property(bool, notify=summary_changed) # type: ignore[arg-type] - def busy(self) -> bool: - return self._busy - - @Property(str, notify=summary_changed) # type: ignore[arg-type] - def filter(self) -> str: - return self._filter diff --git a/src/pxmodrim/ui/plugins/downloads/plugin.py b/src/pxmodrim/ui/plugins/downloads/plugin.py deleted file mode 100644 index 3b8aece..0000000 --- a/src/pxmodrim/ui/plugins/downloads/plugin.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.plugins.downloads.view import DownloadsViewPanel - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - - -class DownloadsUiPlugin(Plugin): - name = "downloads_ui" - dependencies: ClassVar[list[str]] = ["downloads"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - ctx.add_rail_view(DownloadsViewPanel) diff --git a/src/pxmodrim/ui/plugins/downloads/view.py b/src/pxmodrim/ui/plugins/downloads/view.py deleted file mode 100644 index 456d2c8..0000000 --- a/src/pxmodrim/ui/plugins/downloads/view.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from loguru import logger -from PySide6.QtCore import QUrl, Slot -from PySide6.QtGui import QColor -from PySide6.QtQuickWidgets import QQuickWidget -from qasync import asyncSlot - -from pxmodrim.core.downloads import download_manager -from pxmodrim.ui.plugins.downloads.model import DownloadsModel -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - from PySide6.QtWidgets import QWidget - - from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads import DownloadResult - from pxmodrim.ui.context import AppContext - -_QML = Path(__file__).parent / "Downloads.qml" - - -class DownloadsViewPanel(BaseViewPanel): - view_id = "downloads" - lists_downloads = True - icon_name = "download" - label = "Downloads" - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - self._downloads = download_manager(ctx) - self.model = DownloadsModel(self) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setObjectName("downloadsView") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("downloadsPanel", self) - qml_ctx.setContextProperty("downloadsModel", self.model) - self._qml.setSource(QUrl.fromLocalFile(str(_QML))) - self._root.addWidget(self._qml, 1) - - self.model.set_busy(self._downloads.is_downloading) - self._downloads.batch_started.connect(self._on_batch_started) - self._downloads.download_item_status_changed.connect(self.model.apply) - self._downloads.download_item_titled.connect(self.model.set_title) - self._downloads.download_phase_changed.connect(self.model.set_phase) - self._downloads.download_finished.connect(self._on_finished) - self._downloads.busy_changed.connect(self.model.set_busy) - - def _on_batch_started(self, ids: list[str]) -> None: - self.model.begin(ids, self._downloads.names_by_id(self._ctx.all_mods.values())) - - def _on_finished(self, result: DownloadResult) -> None: - self.model.finish(result) - - @Slot() - def stop(self) -> None: - self._downloads.cancel() - - @asyncSlot() - async def retryFailed(self) -> None: - ids = self.model.failed_ids() - if not ids: - return - logger.info("[downloads] retrying {} failed mods", len(ids)) - try: - await self._downloads.download_mods(ids) - except (RuntimeError, ValueError) as exc: - logger.warning("[downloads] retry not started: {}", exc) diff --git a/src/pxmodrim/ui/plugins/organizer/__init__.py b/src/pxmodrim/ui/plugins/organizer/__init__.py deleted file mode 100644 index e5b4340..0000000 --- a/src/pxmodrim/ui/plugins/organizer/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.organizer.plugin import OrganizerUiPlugin -from pxmodrim.ui.plugins.organizer.tree_model import ModTreeModel -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel - -__all__ = ["ModTreeModel", "OrganizerUiPlugin", "OrganizerViewPanel"] diff --git a/src/pxmodrim/ui/plugins/organizer/dialogs.py b/src/pxmodrim/ui/plugins/organizer/dialogs.py deleted file mode 100644 index 6fcf6e0..0000000 --- a/src/pxmodrim/ui/plugins/organizer/dialogs.py +++ /dev/null @@ -1,139 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import Qt -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QLineEdit, - QMessageBox, - QTreeWidget, - QTreeWidgetItem, - QVBoxLayout, - QWidget, -) - -from pxmodrim.core.organizer import ROOT_ID, FolderNode -from pxmodrim.ui.components.button import AppButton - - -class FolderNameDialog(QDialog): - def __init__( - self, title: str, initial: str, parent: QWidget, verb: str | None = None - ) -> None: - super().__init__(parent) - self.setWindowTitle(title) - self.setMinimumWidth(360) - layout = QVBoxLayout(self) - layout.addWidget(QLabel("Folder name", self)) - self._edit = QLineEdit(initial, self) - layout.addWidget(self._edit) - primary_text = verb or title.split()[0] - self._ok = AppButton(primary_text, self) - self._ok.setObjectName("primaryAction") - self._ok.setDefault(True) - cancel = AppButton("Cancel", self) - self._ok.clicked.connect(self.accept) - cancel.clicked.connect(self.reject) - row = QHBoxLayout() - row.addStretch(1) - row.addWidget(cancel) - row.addWidget(self._ok) - layout.addLayout(row) - self._edit.textChanged.connect(self._validate) - self._edit.returnPressed.connect(self._ok.click) - self._validate(initial) - self._edit.selectAll() - - def _validate(self, text: str) -> None: - self._ok.setEnabled(bool(text.strip())) - - def textValue(self) -> str: - return self._edit.text().strip() - - -class DeleteFolderDialog(QMessageBox): - def __init__(self, name: str, parent: QWidget) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Warning) - self.setWindowTitle("Delete Folder") - self.setText(f"Delete ‘{name}’ and its subfolders?") - self.setInformativeText( - "Their mods become ungrouped. Load order is not affected." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - self.setEscapeButton(QMessageBox.StandardButton.Cancel) - yes = self.button(QMessageBox.StandardButton.Yes) - if yes: - yes.setText("Delete") - yes.setObjectName("primaryAction") - - -class FolderPickerDialog(QDialog): - def __init__( - self, - tree: FolderNode, - title: str, - allowed: frozenset[int], - parent: QWidget, - verb: str = "Move", - ) -> None: - super().__init__(parent) - self.setWindowTitle(title) - self.resize(380, 450) - layout = QVBoxLayout(self) - layout.addWidget(QLabel("Destination folder")) - self._tree = QTreeWidget(self) - self._tree.setHeaderHidden(True) - self._tree.setRootIsDecorated(True) - layout.addWidget(self._tree) - top = QTreeWidgetItem(self._tree, ["Ungrouped (top level)"]) - top.setData(0, Qt.ItemDataRole.UserRole, ROOT_ID) - if ROOT_ID not in allowed: - top.setDisabled(True) - - def add(parent: QTreeWidgetItem, node: FolderNode) -> None: - item = QTreeWidgetItem(parent, [node.folder.name]) - item.setData(0, Qt.ItemDataRole.UserRole, node.folder.id) - if node.folder.id not in allowed: - item.setDisabled(True) - for child in node.children: - add(item, child) - - for folder in tree.children: - add(top, folder) - self._tree.expandAll() - self._ok = AppButton(verb, self) - self._ok.setObjectName("primaryAction") - self._ok.setDefault(True) - self._ok.setEnabled(False) - cancel = AppButton("Cancel", self) - self._tree.currentItemChanged.connect(self._update_ok) - self._tree.itemDoubleClicked.connect( - lambda _item, _column: self._accept_valid() - ) - self._ok.clicked.connect(self._accept_valid) - cancel.clicked.connect(self.reject) - row = QHBoxLayout() - row.addStretch(1) - row.addWidget(cancel) - row.addWidget(self._ok) - layout.addLayout(row) - - def _update_ok( - self, item: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None - ) -> None: - self._ok.setEnabled(item is not None and not item.isDisabled()) - - def _accept_valid(self) -> None: - if self._ok.isEnabled(): - self.accept() - - @property - def folder_id(self) -> int: - item = self._tree.currentItem() - assert item is not None - return int(item.data(0, Qt.ItemDataRole.UserRole)) diff --git a/src/pxmodrim/ui/plugins/organizer/filter_model.py b/src/pxmodrim/ui/plugins/organizer/filter_model.py deleted file mode 100644 index f9ef7f9..0000000 --- a/src/pxmodrim/ui/plugins/organizer/filter_model.py +++ /dev/null @@ -1,102 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from PySide6.QtCore import ( - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - -from pxmodrim.core.organizer import TreeFilter - -_ICONS = {"all": "grid", "active": "check-circle", "inactive": "ban"} -_PROVIDER_ICONS = { - "steam": "steam", - "downloaded": "steam", - "core": "grid", - "git": "git", -} -_TAG_ICON = "tag" - - -class OrganizerFilterModel(QAbstractListModel): - LabelRole = Qt.ItemDataRole.UserRole + 1 - CountRole = Qt.ItemDataRole.UserRole + 2 - IconRole = Qt.ItemDataRole.UserRole + 3 - SectionRole = Qt.ItemDataRole.UserRole + 4 - KeyRole = Qt.ItemDataRole.UserRole + 5 - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._items: list[TreeFilter] = [] - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - return 0 if parent is not None and parent.isValid() else len(self._items) - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or not 0 <= index.row() < len(self._items): - return None - entry = self._items[index.row()] - if role in (self.LabelRole, Qt.ItemDataRole.DisplayRole): - return entry.label - if role == self.CountRole: - return entry.count - if role == self.IconRole: - if entry.tag_id is not None or entry.key.startswith("tag:"): - return _TAG_ICON - return _ICONS.get( - entry.key, _PROVIDER_ICONS.get(entry.provider_id or "", "folder") - ) - if role == self.SectionRole: - if entry.tag_id is not None or entry.key.startswith("tag:"): - return "Tags" - if entry.provider_id is not None or entry.key.startswith("provider:"): - return "Providers" - return "Status" - if role == self.KeyRole: - return entry.key - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - Qt.ItemDataRole.DisplayRole: QByteArray(b"filterLabel"), - self.CountRole: QByteArray(b"count"), - self.IconRole: QByteArray(b"iconName"), - self.SectionRole: QByteArray(b"sectionName"), - self.KeyRole: QByteArray(b"key"), - } - - def update(self, entries: list[TreeFilter]) -> None: - if [item.key for item in self._items] != [item.key for item in entries]: - self.beginResetModel() - self._items = entries - self.endResetModel() - else: - self._items = entries - if entries: - self.dataChanged.emit( - self.index(0), - self.index(len(entries) - 1), - [self.CountRole, self.LabelRole, Qt.ItemDataRole.DisplayRole], - ) - - def key_at(self, row: int) -> str | None: - return self._items[row].key if 0 <= row < len(self._items) else None - - def for_key(self, key: str) -> TreeFilter: - for item in self._items: - if item.key == key: - return item - if self._items: - return self._items[0] - return TreeFilter("all", "All", 0) diff --git a/src/pxmodrim/ui/plugins/organizer/plugin.py b/src/pxmodrim/ui/plugins/organizer/plugin.py deleted file mode 100644 index 5462440..0000000 --- a/src/pxmodrim/ui/plugins/organizer/plugin.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, ClassVar - -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - - -class OrganizerUiPlugin(Plugin): - name = "organizer_ui" - dependencies: ClassVar[list[str]] = ["organizer"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - ctx.add_rail_view(OrganizerViewPanel, position=0) diff --git a/src/pxmodrim/ui/plugins/organizer/tree_model.py b/src/pxmodrim/ui/plugins/organizer/tree_model.py deleted file mode 100644 index 42109b6..0000000 --- a/src/pxmodrim/ui/plugins/organizer/tree_model.py +++ /dev/null @@ -1,453 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable, Iterable, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Literal - -from PySide6.QtCore import ( - QAbstractItemModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.organizer import ROOT_ID, FolderNode, ModLeaf, Tag -from pxmodrim.ui.models.mod_list_model import provider_label -from pxmodrim.ui.theme.palette import PALETTE - -if TYPE_CHECKING: - from pxmodrim.core.models.metadata.structures import ListedMod - from pxmodrim.core.models.view.diagnostics import ModDiagnosticsView - -NodeKind = Literal["root", "folder", "ungrouped", "mod"] -AnyIndex = QModelIndex | QPersistentModelIndex - -UNGROUPED_KEY = "ungrouped" - -PROVIDER_ICONS: dict[str, str] = { - "steam": "steam", - "downloaded": "steam", - "local": "folder", - "core": "grid", - "git": "git", -} - -_CHECK_STATES = { - "on": Qt.CheckState.Checked, - "off": Qt.CheckState.Unchecked, - "partial": Qt.CheckState.PartiallyChecked, -} - - -class TreeNode: - """One row of the organizer tree; the model's internal pointer target.""" - - __slots__ = ( - "children", - "folder", - "key", - "kind", - "leaf", - "mod_version", - "parent", - "provider_id", - "provider_label", - "row", - ) - - def __init__( - self, - kind: NodeKind, - key: str, - parent: TreeNode | None, - folder: FolderNode | None = None, - leaf: ModLeaf | None = None, - provider_id: str = "", - ) -> None: - self.kind: NodeKind = kind - self.key = key - self.parent = parent - self.folder = folder - self.leaf = leaf - self.provider_id = provider_id - self.provider_label = "" - self.mod_version = "" - self.children: list[TreeNode] = [] - self.row = 0 - - def add(self, child: TreeNode) -> None: - child.row = len(self.children) - self.children.append(child) - - def walk(self) -> Iterator[TreeNode]: - for child in self.children: - yield child - yield from child.walk() - - @property - def folder_id(self) -> int: - """Folder this node is (folder/ungrouped) or sits in (mod).""" - if self.kind == "folder" and self.folder is not None: - return self.folder.folder.id - if self.kind == "mod" and self.parent is not None: - return self.parent.folder_id - return ROOT_ID - - @property - def is_container(self) -> bool: - return self.kind in ("folder", "ungrouped") - - -def _build( - root: FolderNode, mods: Mapping[str, ListedMod], filtering: bool -) -> TreeNode: - top = TreeNode("root", "", None) - - def add_mods(parent: TreeNode, leaves: Iterable[ModLeaf]) -> None: - for leaf in leaves: - mod = mods.get(leaf.uuid) - provider = mod.provider_id if mod is not None else "" - node = TreeNode( - "mod", f"m:{leaf.uuid}", parent, leaf=leaf, provider_id=provider - ) - if mod is not None: - node.provider_label = provider_label( - provider, str(getattr(mod, "package_id", "")) - ) - node.mod_version = ( - mod.mod_version if isinstance(mod, AboutXmlMod) else "" - ) - parent.add(node) - - def add_folder(parent: TreeNode, node: FolderNode) -> None: - item = TreeNode("folder", f"f:{node.folder.id}", parent, folder=node) - parent.add(item) - for child in node.children: - add_folder(item, child) - add_mods(item, node.mods) - - for child in root.children: - add_folder(top, child) - if root.mods or not filtering: - ungrouped = TreeNode("ungrouped", UNGROUPED_KEY, top, folder=root) - top.add(ungrouped) - add_mods(ungrouped, root.mods) - return top - - -def _same_shape(a: TreeNode, b: TreeNode) -> bool: - if a.key != b.key or len(a.children) != len(b.children): - return False - return all(_same_shape(x, y) for x, y in zip(a.children, b.children, strict=True)) - - -def _copy_payload(dst: TreeNode, src: TreeNode) -> None: - dst.folder = src.folder - dst.leaf = src.leaf - dst.provider_id = src.provider_id - dst.provider_label = src.provider_label - dst.mod_version = src.mod_version - for d, s in zip(dst.children, src.children, strict=True): - _copy_payload(d, s) - - -class ModTreeModel(QAbstractItemModel): - """Organizer folders (and the trailing Ungrouped node) with their mods.""" - - KindRole = Qt.ItemDataRole.UserRole + 1 - KeyRole = Qt.ItemDataRole.UserRole + 2 - UuidRole = Qt.ItemDataRole.UserRole + 3 - PackageIdRole = Qt.ItemDataRole.UserRole + 4 - CheckStateRole = Qt.ItemDataRole.UserRole + 5 - EnabledCountRole = Qt.ItemDataRole.UserRole + 6 - TotalCountRole = Qt.ItemDataRole.UserRole + 7 - VisibleCountRole = Qt.ItemDataRole.UserRole + 8 - FilteringRole = Qt.ItemDataRole.UserRole + 9 - HasRuleRole = Qt.ItemDataRole.UserRole + 10 - ProviderIconRole = Qt.ItemDataRole.UserRole + 11 - ProviderColorRole = Qt.ItemDataRole.UserRole + 12 - HasErrorRole = Qt.ItemDataRole.UserRole + 13 - ErrorTooltipRole = Qt.ItemDataRole.UserRole + 14 - CanPlaceRole = Qt.ItemDataRole.UserRole + 15 - SelectedRole = Qt.ItemDataRole.UserRole + 16 - HasWarningRole = Qt.ItemDataRole.UserRole + 17 - WarningTooltipRole = Qt.ItemDataRole.UserRole + 18 - TagsRole = Qt.ItemDataRole.UserRole + 19 - ProviderLabelRole = Qt.ItemDataRole.UserRole + 20 - ModVersionRole = Qt.ItemDataRole.UserRole + 21 - - _SELECTION_ROLES = (SelectedRole,) - _ERROR_ROLES = (HasErrorRole, ErrorTooltipRole, HasWarningRole, WarningTooltipRole) - - def __init__( - self, - summary_for: Callable[[str], ModDiagnosticsView | None], - parent: QObject | None = None, - ) -> None: - super().__init__(parent) - self._summary_for = summary_for - self._root = TreeNode("root", "", None) - self._filtering = False - self._provider_colors: Mapping[str, str] = {} - self._tag_rows: dict[str, list[dict[str, str]]] = {} - self._selected: set[str] = set() - - def _node(self, index: AnyIndex | None) -> TreeNode: - if index is None or not index.isValid(): - return self._root - return index.internalPointer() - - def index( - self, row: int, column: int, parent: AnyIndex | None = None - ) -> QModelIndex: - node = self._node(parent) - if column != 0 or not 0 <= row < len(node.children): - return QModelIndex() - return self.createIndex(row, 0, node.children[row]) - - def parent(self, child: AnyIndex | None = None) -> QModelIndex: # type: ignore[override] # pyright: ignore[reportIncompatibleMethodOverride] - if child is None or not child.isValid(): - return QModelIndex() - up = self._node(child).parent - if up is None or up is self._root: - return QModelIndex() - return self.createIndex(up.row, 0, up) - - def rowCount(self, parent: AnyIndex | None = None) -> int: - if parent is not None and parent.column() > 0: - return 0 - return len(self._node(parent).children) - - def columnCount(self, parent: AnyIndex | None = None) -> int: - return 1 - - def data(self, index: AnyIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: - if not index.isValid(): - return None - node: TreeNode = index.internalPointer() - if role == Qt.ItemDataRole.DisplayRole: - return self._name(node) - if role == self.KindRole: - return node.kind - if role == self.KeyRole: - return node.key - if role == self.SelectedRole: - return node.key in self._selected - if role == self.FilteringRole: - return self._filtering - if node.kind == "mod": - return self._mod_data(node, role) - return self._folder_data(node, role) - - def _name(self, node: TreeNode) -> str: - if node.kind == "ungrouped": - return "Ungrouped" - if node.kind == "folder" and node.folder is not None: - return node.folder.folder.name - return node.leaf.name if node.leaf is not None else "" - - def _folder_data(self, node: TreeNode, role: int) -> Any: - folder = node.folder - if folder is None: - return None - own = node.kind == "ungrouped" - if role == self.CheckStateRole: - return _CHECK_STATES[folder.own_check if own else folder.check] - if role == self.EnabledCountRole: - return folder.own_enabled if own else folder.enabled - if role == self.TotalCountRole: - return folder.own_total if own else folder.total - if role == self.VisibleCountRole: - return len(folder.mods) if own else folder.visible_total - if role == self.HasRuleRole: - return folder.has_rules and not own - return None - - def _mod_data(self, node: TreeNode, role: int) -> Any: - leaf = node.leaf - if leaf is None: - return None - if role == self.UuidRole: - return leaf.uuid - if role == self.PackageIdRole: - return leaf.package_id or "" - if role == self.CheckStateRole: - return Qt.CheckState.Checked if leaf.enabled else Qt.CheckState.Unchecked - if role == self.ProviderLabelRole: - return node.provider_label - if role == self.ModVersionRole: - return node.mod_version - if role == self.ProviderIconRole: - return PROVIDER_ICONS.get(node.provider_id, "folder") - if role == self.ProviderColorRole: - return self._provider_colors.get(node.provider_id, PALETTE["TEXT_DIM"]) - if role == self.CanPlaceRole: - return leaf.package_id is not None - if role == self.TagsRole: - return self._tag_rows.get(leaf.package_id or "", []) - if role in self._ERROR_ROLES: - summary = self._summary_for(leaf.uuid) if leaf.enabled else None - if role == self.HasErrorRole: - return summary is not None and summary.has_errors - if role == self.HasWarningRole: - return summary is not None and summary.has_warnings - if role == self.ErrorTooltipRole: - return summary.error_tooltip if summary is not None else "" - return summary.warning_tooltip if summary is not None else "" - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - Qt.ItemDataRole.DisplayRole: QByteArray(b"name"), - self.KindRole: QByteArray(b"kind"), - self.KeyRole: QByteArray(b"key"), - self.UuidRole: QByteArray(b"uuid"), - self.PackageIdRole: QByteArray(b"packageId"), - self.CheckStateRole: QByteArray(b"checkState"), - self.EnabledCountRole: QByteArray(b"enabledCount"), - self.TotalCountRole: QByteArray(b"totalCount"), - self.VisibleCountRole: QByteArray(b"visibleCount"), - self.FilteringRole: QByteArray(b"filtering"), - self.HasRuleRole: QByteArray(b"hasRule"), - self.ProviderIconRole: QByteArray(b"providerIcon"), - self.ProviderLabelRole: QByteArray(b"providerLabel"), - self.ModVersionRole: QByteArray(b"modVersion"), - self.ProviderColorRole: QByteArray(b"providerColor"), - self.HasErrorRole: QByteArray(b"hasError"), - self.ErrorTooltipRole: QByteArray(b"errorTooltip"), - self.HasWarningRole: QByteArray(b"hasWarning"), - self.WarningTooltipRole: QByteArray(b"warningTooltip"), - self.CanPlaceRole: QByteArray(b"canPlace"), - self.SelectedRole: QByteArray(b"selected"), - self.TagsRole: QByteArray(b"tags"), - } - - @property - def filtering(self) -> bool: - return self._filtering - - def set_tree( - self, - root: FolderNode, - mods: Mapping[str, ListedMod], - provider_colors: Mapping[str, str], - filtering: bool, - tags: Mapping[int, Tag] | None = None, - ) -> None: - """Swap in a freshly built tree. - - Same-shape rebuilds (check toggles, counts, collapse flags) update in - place so the view keeps its expansion and scroll position. - """ - fresh = _build(root, mods, filtering) - self._provider_colors = provider_colors - tag_map = tags if tags is not None else {} - self._tag_rows = { - leaf.package_id: [ - {"name": tag_map[tag_id].name, "color": tag_map[tag_id].color} - for tag_id in sorted(leaf.tag_ids) - if tag_id in tag_map - ] - for leaf in root.all_mods() - if leaf.package_id and leaf.tag_ids - } - if self._filtering == filtering and _same_shape(self._root, fresh): - _copy_payload(self._root, fresh) - self._emit_all_changed(self._root) - else: - self.beginResetModel() - self._root = fresh - self._filtering = filtering - self.endResetModel() - live = {node.key for node in self._root.walk()} - self._selected &= live - - def _emit_all_changed(self, node: TreeNode, roles: Iterable[int] = ()) -> None: - if not node.children: - return - parent = self._index_of(node) - top = self.index(0, 0, parent) - bottom = self.index(len(node.children) - 1, 0, parent) - self.dataChanged.emit(top, bottom, list(roles)) - for child in node.children: - self._emit_all_changed(child, roles) - - def refresh_diagnostics(self) -> None: - self._emit_all_changed(self._root, self._ERROR_ROLES) - - def _index_of(self, node: TreeNode) -> QModelIndex: - if node is self._root: - return QModelIndex() - return self.createIndex(node.row, 0, node) - - def node_at(self, index: AnyIndex) -> TreeNode | None: - return index.internalPointer() if index.isValid() else None - - def index_for_key(self, key: str) -> QModelIndex: - for node in self._root.walk(): - if node.key == key: - return self._index_of(node) - return QModelIndex() - - def index_for_uuid(self, uuid: str) -> QModelIndex: - for node in self._root.walk(): - if node.kind == "mod" and node.leaf is not None and node.leaf.uuid == uuid: - return self._index_of(node) - return QModelIndex() - - def expanded_indexes(self, parent: AnyIndex | None = None) -> list[QModelIndex]: - """Containers under *parent* the view should expand, parents first. - - Everything opens while filtering so matches are never hidden; otherwise - the persisted ``collapsed`` flag decides and closed folders hide their - subtree. - """ - result: list[QModelIndex] = [] - - def visit(node: TreeNode) -> None: - for child in node.children: - if child.is_container and self._is_open(child): - result.append(self._index_of(child)) - visit(child) - - visit(self._node(parent)) - return result - - def _is_open(self, node: TreeNode) -> bool: - if self._filtering: - return True - return node.folder is not None and not node.folder.folder.collapsed - - def selected_nodes(self) -> list[TreeNode]: - return [node for node in self._root.walk() if node.key in self._selected] - - def is_selected(self, index: AnyIndex) -> bool: - node = self.node_at(index) - return node is not None and node.key in self._selected - - def select(self, indexes: Iterable[AnyIndex], mode: str = "replace") -> None: - """Apply a click to the selection: ``replace``, ``toggle`` or ``extend``.""" - keys = [n.key for n in map(self.node_at, indexes) if n is not None] - before = set(self._selected) - if mode == "replace": - self._selected = set(keys) - elif mode == "toggle": - self._selected ^= set(keys) - else: - self._selected |= set(keys) - self._notify_selection(before ^ self._selected) - - def clear_selection(self) -> None: - before = set(self._selected) - self._selected.clear() - self._notify_selection(before) - - def _notify_selection(self, changed: set[str]) -> None: - if not changed: - return - for node in self._root.walk(): - if node.key in changed: - index = self._index_of(node) - self.dataChanged.emit(index, index, list(self._SELECTION_ROLES)) diff --git a/src/pxmodrim/ui/plugins/organizer/view.py b/src/pxmodrim/ui/plugins/organizer/view.py deleted file mode 100644 index c8ccf4e..0000000 --- a/src/pxmodrim/ui/plugins/organizer/view.py +++ /dev/null @@ -1,902 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from loguru import logger -from PySide6.QtCore import ( - Property, - QMetaObject, - QModelIndex, - Qt, - QTimer, - QUrl, - Signal, - Slot, -) -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import ( - QDialog, - QHBoxLayout, - QLabel, - QLineEdit, - QMenu, - QMessageBox, - QSizePolicy, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.organizer import ( - MAX_DEPTH, - ROOT_ID, - OrganizerError, - OrganizerService, - RuleField, - RuleOp, - RuleSpec, -) -from pxmodrim.core.organizer.resolve import preview_rule_matches -from pxmodrim.ui.components.button import AppButton -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome -from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.filter_sidebar import FilterSidebar -from pxmodrim.ui.components.icon_button import IconButton -from pxmodrim.ui.components.mod_activation import apply_activation, toggle_mods -from pxmodrim.ui.components.mod_updates import ( - add_update_action, - update_mods, - update_state, -) -from pxmodrim.ui.mod_selection import ModSelectionPresenter -from pxmodrim.ui.panels.mod_info_panel import ModInfoPanel -from pxmodrim.ui.plugins.organizer.dialogs import ( - DeleteFolderDialog, - FolderNameDialog, - FolderPickerDialog, -) -from pxmodrim.ui.plugins.organizer.filter_model import OrganizerFilterModel -from pxmodrim.ui.plugins.organizer.tree_model import ModTreeModel, TreeNode -from pxmodrim.ui.theme.constants import SIDEBAR_WIDTH -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - -_QML_DIR = Path(__file__).parent - - -def _plural(count: int, singular: str, plural: str | None = None) -> str: - return f"{count} {singular if count == 1 else plural or singular + 's'}" - - -class OrganizerViewPanel(BaseViewPanel): - view_id = "organizer" - icon_name = "folder" - label = "Organizer" - - readyChanged = Signal() - editorChanged = Signal() - tagCreated = Signal() - tagUpdated = Signal(int) - rulesSaved = Signal() - rulePreviewReady = Signal(int, "QVariantMap") # pyright: ignore[reportArgumentType] - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - self._service = cast(OrganizerService, ctx.plugins.get("organizer")) - self.model = ModTreeModel(ctx.diagnostics_service.summary_for, self) - self.filters = OrganizerFilterModel(self) - self._filter_key = "all" - self._search_text = "" - self._dirty = True - self._selection_anchor = QModelIndex() - self._selected_uuid: str | None = None - self._editor_error = "" - - self._tasks: set[asyncio.Task[None]] = set() - content = QWidget(self) - content.setObjectName("contentArea") - row = QHBoxLayout(content) - row.setContentsMargins(0, 0, 0, 0) - row.setSpacing(0) - - self._sidebar = FilterSidebar(qml_engine, self.filters, content) - self._sidebar.setFixedWidth(SIDEBAR_WIDTH) - self._sidebar.activated.connect(self._choose_filter_at) - row.addWidget(self._sidebar) - - main = QWidget(content) - main_layout = QVBoxLayout(main) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - toolbar = QWidget(main) - toolbar.setObjectName("searchBox") - toolbar.setFixedHeight(56) - toolbar_layout = QHBoxLayout(toolbar) - toolbar_layout.setContentsMargins(16, 8, 16, 8) - self.search_input = QLineEdit(toolbar) - self.search_input.setPlaceholderText("Search mods, package ID, author…") - self.search_input.setClearButtonEnabled(True) - toolbar_layout.addWidget(self.search_input, 1) - create = AppButton("New folder", toolbar) - create.setObjectName("primaryAction") - create.clicked.connect(self.newFolder) - toolbar_layout.addWidget(create) - self.rules_button = AppButton("Auto-folder rules", toolbar) - self.rules_button.clicked.connect(self.openRules) - toolbar_layout.addWidget(self.rules_button) - for label, method in ( - ("Expand all", "expandAll"), - ("Collapse all", "collapseAll"), - ): - button = AppButton(label, toolbar) - button.clicked.connect( - lambda _checked=False, name=method: self._tree_command(name) - ) - toolbar_layout.addWidget(button) - main_layout.addWidget(toolbar) - - self._qml = QQuickWidget(qml_engine, main) # pyright: ignore[reportCallIssue, reportArgumentType] - self._qml.setObjectName("organizerTree") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_1"])) - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("organizerPanel", self) - qml_ctx.setContextProperty("modTreeModel", self.model) - self._qml.setSource(QUrl.fromLocalFile(str(_QML_DIR / "ModTree.qml"))) - rules_flags = ( - Qt.WindowType.Dialog - | Qt.WindowType.WindowTitleHint - | Qt.WindowType.WindowSystemMenuHint - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowMinMaxButtonsHint - ) - self._rules_dialog = QDialog(self, rules_flags) - self._rules_dialog.setObjectName("organizerRuleEditorWindow") - self._rules_dialog.setWindowTitle("Auto-Folder Rules") - self._rules_dialog.setWindowModality(Qt.WindowModality.WindowModal) - self._rules_dialog.setMinimumSize(720, 520) - self._rules_dialog.setSizeGripEnabled(True) - self._rules_dialog_opened_once = False - rules_layout = QVBoxLayout(self._rules_dialog) - rules_layout.setContentsMargins(0, 0, 0, 0) - self._rules_qml = QQuickWidget(qml_engine, self._rules_dialog) # pyright: ignore[reportCallIssue, reportArgumentType] - self._rules_qml.setObjectName("organizerRuleEditorQuickWidget") - self._rules_qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._rules_qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - self._rules_qml.rootContext().setContextProperty("organizerPanel", self) - self._rules_qml.setSource(QUrl.fromLocalFile(str(_QML_DIR / "RuleEditor.qml"))) - rules_layout.addWidget(self._rules_qml) - install_dialog_chrome(self._rules_dialog) - self._rules_dialog.finished.connect(self._on_rules_dialog_finished) - hint = QWidget(main) - hint.setObjectName("organizerHint") - hint_layout = QHBoxLayout(hint) - hint_layout.setContentsMargins(12, 5, 12, 5) - hint_label = QLabel( - "Folders and tags are for browsing only. " - "Edit load order in Mods; checkboxes enable or disable the same mods.", - hint, - ) - hint_label.setWordWrap(True) - hint_layout.addWidget(hint_label, 1) - dismiss = IconButton("close", "Dismiss", size=24, parent=hint) - dismiss.clicked.connect(hint.hide) - hint_layout.addWidget(dismiss) - main_layout.addWidget(hint) - main_layout.addWidget(self._qml, 1) - self._selection_bar = QWidget(main) - bar_layout = QHBoxLayout(self._selection_bar) - bar_layout.setContentsMargins(12, 6, 12, 6) - self._selection_label = QLabel(self._selection_bar) - bar_layout.addWidget(self._selection_label) - for label, action in ( - ("Enable", "enable"), - ("Disable", "disable"), - ("Move to folder…", "move"), - ("Manage tags…", "tags"), - ("Create folder from selection", "create"), - ("Update", "update"), - ): - button = AppButton(label, self._selection_bar) - if action == "update": - self._update_button = button - button.clicked.connect( - lambda _checked=False, name=action: self.selectionAction(name) - ) - bar_layout.addWidget(button) - self._selection_bar.hide() - main_layout.addWidget(self._selection_bar) - row.addWidget(main, 3) - self.mod_info = ModInfoPanel( - self._ctx, self._qml_engine, ui_prefs=self._ui_prefs - ) - self.mod_info.setObjectName("modInfoPanel") - self.mod_info.setMinimumWidth(300) - row.addWidget(self.mod_info, 2) - self._selection = ModSelectionPresenter(self._ctx, self.mod_info) - self.mod_info.mod_requested.connect(self._reveal_mod) - self._root.addWidget(content, 1) - self._status = QLabel(self) - self._status.setObjectName("organizerStatus") - self._root.addWidget(self._status) - - self._menu = QMenu(self) - self._menu.setToolTipsVisible(True) - self._search_timer = QTimer(self) - self.model.modelReset.connect(self._clear_selection_anchor) - self._search_timer.setSingleShot(True) - self._search_timer.setInterval(220) - self.search_input.textChanged.connect(self._search_timer.start) - self._search_timer.timeout.connect(self._search) - self._service.changed.connect(self._invalidate) - ctx.active_state_changed.connect(self._invalidate) - ctx.mod_service.mods_changed.connect(self._invalidate) - ctx.diagnostics_service.diagnostics_summary_changed.connect( - self._refresh_diagnostics - ) - self._service.changed.connect(self._notify_editor) - download_manager(ctx).busy_changed.connect(lambda _busy: self._sync_info()) - - @Property(bool, notify=readyChanged) - def ready(self) -> bool: - return self._service.ready - - def _notify_editor(self, _event: object) -> None: - self.editorChanged.emit() - - def showEvent(self, event: object) -> None: - super().showEvent(event) # type: ignore[arg-type] - if self._dirty: - self._rebuild() - rules_root = self._rules_qml.rootObject() - if rules_root is not None: - QMetaObject.invokeMethod(rules_root, "schedulePreview") - - def _position_rules_dialog(self) -> None: - host = self.window() - host_geometry = host.frameGeometry() - if not self._rules_dialog_opened_once: - available = host.screen().availableGeometry() - width = min( - 960, - max(800, host_geometry.width() - 48), - available.width() - 32, - ) - height = min( - 760, - max(600, host_geometry.height() - 80), - available.height() - 32, - ) - self._rules_dialog.resize(width, height) - self._rules_dialog_opened_once = True - self._rules_dialog.move( - host_geometry.center() - self._rules_dialog.rect().center() - ) - - def _reveal_mod(self, uuid: str) -> None: - index = self.model.index_for_uuid(uuid) - if index.isValid(): - self.selectSingle(index) - - def _clear_selection_anchor(self) -> None: - self._selection_anchor = QModelIndex() - - def _sync_info(self, force: bool = False) -> None: - selected = self.model.selected_nodes() - self.editorChanged.emit() - mod_count = sum(node.kind == "mod" for node in selected) - self._selection_label.setText(f"{_plural(mod_count, 'mod')} selected") - self._selection_bar.setVisible(mod_count > 1) - state = update_state( - self._ctx, [n.leaf.uuid for n in selected if n.kind == "mod" and n.leaf] - ) - self._update_button.setVisible(download_manager(self._ctx).available) - self._update_button.setText(state.label) - self._update_button.setEnabled(state.enabled) - self._update_button.setToolTip(state.tooltip) - node = selected[0] if len(selected) == 1 else None - uuid = node.leaf.uuid if node is not None and node.leaf is not None else None - if uuid == self._selected_uuid and not force: - return - self._selected_uuid = uuid - if uuid is None: - self._selection.clear() - else: - task = asyncio.create_task(self._selection.show(uuid)) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - def _invalidate(self, _event: object = None) -> None: - self._dirty = True - if self.isVisible(): - self._rebuild() - - def _refresh_diagnostics(self, _event: object) -> None: - self.model.refresh_diagnostics() - if self._selected_uuid is not None: - self.mod_info.set_issues( - self._ctx.diagnostics_service.issues_for(self._selected_uuid) - ) - - def _rebuild(self) -> None: - if not self._service.ready: - return - self._dirty = False - self.filters.update(self._service.tree_filters()) - query = self.filters.for_key(self._filter_key).query(self._search_text) - tree = self._service.tree(query) - self.model.set_tree( - tree, - self._ctx.all_mods, - self._ctx.mod_service.provider_colors, - not query.is_empty, - self._service.state.tags, - ) - full_tree = tree if query.is_empty else self._service.tree() - self._status.setText( - f"{_plural(len(self._ctx.all_mods), 'mod')} · " - f"{len(self._ctx.active_uuids)} active · " - f"{_plural(len(self._service.state.folders) - 1, 'folder')} · " - f"{len(full_tree.mods)} ungrouped" - ) - self._sync_info(force=True) - self.readyChanged.emit() - - def _search(self) -> None: - text = self.search_input.text() - if text != self._search_text: - self._search_text = text - self._invalidate() - - def _choose_filter_at(self, row: int) -> None: - key = self.filters.key_at(row) - if key is not None and key != self._filter_key: - self._filter_key = key - self._invalidate() - - @asyncSlot(str) - async def selectionAction(self, action: str) -> None: - nodes = [node for node in self.model.selected_nodes() if node.leaf is not None] - if not nodes: - return - if action == "tags": - self.openTags() - elif action in ("move", "create"): - pids = self._selected_package_ids() - if pids: - if action == "move": - await self._move_mods(pids) - else: - await self._new_folder_from(pids) - elif action == "update": - await update_mods( - self._ctx, [n.leaf.uuid for n in nodes if n.leaf is not None], self - ) - elif action in ("enable", "disable"): - uuids = [node.leaf.uuid for node in nodes if node.leaf is not None] - if action == "enable": - await self._enable_many(uuids) - else: - await self._disable_many(uuids) - - def _selected_package_ids(self) -> list[str]: - return list( - dict.fromkeys( - node.leaf.package_id - for node in self.model.selected_nodes() - if node.kind == "mod" and node.leaf and node.leaf.package_id - ) - ) - - @Property(int, notify=editorChanged) - def selectedTagCount(self) -> int: - return len(self._selected_package_ids()) - - @Property(list, notify=editorChanged) - def tagRows(self) -> list[dict[str, str | int]]: - if not self._service.ready: - return [] - pids = self._selected_package_ids() - rows: list[dict[str, str | int]] = [] - tags = sorted( - self._service.state.tags.values(), key=lambda t: t.name.casefold() - ) - for tag in tags: - matched = sum( - tag.id in self._service.state.mod_tags.get(pid, frozenset()) - for pid in pids - ) - rows.append( - { - "id": tag.id, - "name": tag.name, - "color": tag.color, - "checkState": 2 if 0 < matched < len(pids) else int(bool(matched)), - } - ) - return rows - - @Property(list, notify=editorChanged) - def ruleRows(self) -> list[dict[str, str | int]]: - if not self._service.ready: - return [] - return [ - { - "field": rule.field, - "op": rule.op, - "pattern": rule.pattern, - "folder_id": rule.folder_id, - } - for rule in self._service.state.rules - ] - - @Property(list, notify=editorChanged) - def ruleFolders(self) -> list[dict[str, str | int]]: - if not self._service.ready: - return [] - folders = self._service.state.folders - - def path(folder_id: int) -> str: - folder = folders[folder_id] - if folder.parent_id == ROOT_ID: - return folder.name - assert folder.parent_id is not None - return f"{path(folder.parent_id)} / {folder.name}" - - return [ - {"id": folder.id, "name": path(folder.id)} - for folder in sorted( - (f for f in folders.values() if f.id != ROOT_ID), - key=lambda f: path(f.id).casefold(), - ) - ] - - @Slot("QVariantList", int) - def requestRulePreview( - self, rows: list[dict[str, str | int]], revision: int - ) -> None: - try: - loop = asyncio.get_running_loop() - except RuntimeError: - return - loop.create_task(self._calculate_rule_preview(rows, revision)) - - async def _calculate_rule_preview( - self, rows: list[dict[str, str | int]], revision: int - ) -> None: - specs = [ - RuleSpec( - cast(RuleField, row["field"]), - cast(RuleOp, row["op"]), - str(row["pattern"]), - int(row["folder_id"]), - ) - for row in rows - ] - counts, assignable = await asyncio.to_thread( - preview_rule_matches, self._service.state, self._ctx.all_mods, specs - ) - self.rulePreviewReady.emit( - revision, {"counts": counts, "assignable": assignable} - ) - - @Property(str, notify=editorChanged) - def editorError(self) -> str: - return self._editor_error - - def _set_editor_error(self, message: str = "") -> None: - if message != self._editor_error: - self._editor_error = message - self.editorChanged.emit() - - @Slot() - def openTags(self) -> None: - self._set_editor_error() - root = self._qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, "openTags") - - @Slot() - def openRules(self) -> None: - self._set_editor_error() - root = self._rules_qml.rootObject() - if root is None: - return - self._position_rules_dialog() - self._rules_dialog.open() - self._rules_dialog.activateWindow() - self._rules_qml.setFocus(Qt.FocusReason.OtherFocusReason) - QMetaObject.invokeMethod(root, "prepareForOpen") - - @Slot() - def closeRules(self) -> None: - self._rules_dialog.close() - - @Slot(int) - def _on_rules_dialog_finished(self, _result: int) -> None: - root = self._rules_qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, "closeEditor") - - @asyncSlot(str, str) - async def createTag(self, name: str, color: str) -> None: - try: - await self._service.create_tag(name, color) - self.tagCreated.emit() - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot(int, str, str) - async def updateTag(self, tag_id: int, name: str, color: str) -> None: - try: - await self._service.update_tag(tag_id, name, color) - self.tagUpdated.emit(tag_id) - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot(int) - async def deleteTag(self, tag_id: int) -> None: - try: - await self._service.delete_tag(tag_id) - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot(int, bool) - async def assignTag(self, tag_id: int, add: bool) -> None: - pids = self._selected_package_ids() - try: - if add: - await self._service.set_mod_tags(pids, add=(tag_id,)) - else: - await self._service.set_mod_tags(pids, remove=(tag_id,)) - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot("QVariantList") - async def saveRules(self, rows: list[dict[str, str | int]]) -> None: - specs = [ - RuleSpec( - cast(RuleField, row["field"]), - cast(RuleOp, row["op"]), - str(row["pattern"]), - int(row["folder_id"]), - ) - for row in rows - ] - try: - await self._service.set_rules(specs) - self._set_editor_error() - self.rulesSaved.emit() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @asyncSlot() - async def addStandardRules(self) -> None: - try: - await self._service.add_standard_rules() - self._set_editor_error() - except OrganizerError as exc: - self._set_editor_error(str(exc)) - - @Slot(result="QVariantList") - def expandedIndexes(self) -> list[QModelIndex]: - return self.model.expanded_indexes() - - @Slot(QModelIndex, int, "QVariantList") - def selectRow( - self, index: QModelIndex, modifiers: int, range_indexes: list[QModelIndex] - ) -> None: - if ( - modifiers & Qt.KeyboardModifier.ShiftModifier.value - and self._selection_anchor.isValid() - ): - self.model.select(range_indexes, "replace") - elif modifiers & Qt.KeyboardModifier.ControlModifier.value: - self.model.select((index,), "toggle") - self._selection_anchor = index - else: - self.model.select((index,)) - self._selection_anchor = index - self._sync_info() - - @Slot(QModelIndex) - def selectSingle(self, index: QModelIndex) -> None: - if not self.model.is_selected(index): - self.model.select((index,)) - self._selection_anchor = index - self._sync_info() - - def _tree_command(self, method: str) -> None: - root = self._qml.rootObject() - if root is not None: - QMetaObject.invokeMethod(root, method) - - @asyncSlot(bool) - async def persistAllCollapsed(self, collapsed: bool) -> None: - await self._service.set_all_collapsed(collapsed) - - @asyncSlot(QModelIndex, bool) - async def persistCollapsed(self, index: QModelIndex, collapsed: bool) -> None: - node = self.model.node_at(index) - if node is not None and node.is_container and not self.model.filtering: - try: - await self._service.set_collapsed(node.folder_id, collapsed) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot(QModelIndex) - async def toggleCheck(self, index: QModelIndex) -> None: - node = self.model.node_at(index) - if node is None: - return - if node.kind == "mod" and node.leaf is not None: - await toggle_mods(self._ctx, self, (node.leaf.uuid,)) - elif node.is_container: - await self._toggle_folder(node) - - async def _toggle_folder(self, node: TreeNode) -> None: - enable, disable = self._service.folder_toggle( - node.folder_id, recursive=node.kind != "ungrouped" - ) - if enable or disable: - await apply_activation(self._ctx, self, enable=enable, disable=disable) - - @Slot(QModelIndex) - def contextMenu(self, index: QModelIndex) -> None: - node = self.model.node_at(index) - if node is None: - self._menu.clear() - self._menu.addAction("New folder…", self.newFolder) - elif node.kind == "mod": - self.selectSingle(index) - self._mod_menu() - else: - self.model.select((index,)) - self._folder_menu(node) - self._sync_info() - self._menu.popup(self.cursor().pos()) - - def _mod_menu(self) -> None: - nodes = [n for n in self.model.selected_nodes() if n.kind == "mod" and n.leaf] - uuids = [n.leaf.uuid for n in nodes if n.leaf] - active = set(self._ctx.active_uuids) - self._menu.clear() - if any(uuid not in active for uuid in uuids): - self._menu.addAction("Enable", lambda: self._enable_many(uuids)) - if any(uuid in active for uuid in uuids): - self._menu.addAction("Disable", lambda: self._disable_many(uuids)) - add_update_action( - self._menu, self._ctx, uuids, lambda: self._update_selected(uuids) - ) - self._menu.addSeparator() - pids = list( - dict.fromkeys( - n.leaf.package_id for n in nodes if n.leaf and n.leaf.package_id - ) - ) - placeable = bool(pids) and all(n.leaf and n.leaf.package_id for n in nodes) - tooltip = "Mods without a package ID cannot be placed in folders" - move = self._menu.addAction("Move to folder…", lambda: self._move_mods(pids)) - remove = self._menu.addAction("Remove from folder", lambda: self._ungroup(pids)) - create = self._menu.addAction( - "Create folder from selection…", lambda: self._new_folder_from(pids) - ) - for action in (move, remove, create): - action.setEnabled(placeable) - action.setToolTip(tooltip if not placeable else "") - remove.setEnabled(placeable and any(n.folder_id != ROOT_ID for n in nodes)) - tag_action = self._menu.addAction("Manage tags…", self.openTags) - tag_action.setEnabled(placeable) - manual = list( - dict.fromkeys( - n.leaf.package_id - for n in nodes - if n.leaf and n.leaf.package_id and n.leaf.placement == "manual" - ) - ) - if placeable and manual: - self._menu.addAction("Use auto rules", lambda: self._reset_rules(manual)) - - def _folder_menu(self, node: TreeNode) -> None: - self._menu.clear() - recursive = node.kind != "ungrouped" - uuids = self._service.folder_mod_uuids(node.folder_id, recursive) - self._menu.addAction("Enable all", lambda: self._enable_many(uuids)).setEnabled( - bool(uuids) - ) - self._menu.addAction( - "Disable all", lambda: self._disable_many(uuids) - ).setEnabled(bool(uuids)) - add_update_action( - self._menu, self._ctx, uuids, lambda: self._update_selected(uuids) - ) - self._menu.addSeparator() - if node.kind == "ungrouped": - self._menu.addAction("New folder…", self.newFolder) - return - folder_id = node.folder_id - if node.folder is not None and node.folder.depth < MAX_DEPTH: - self._menu.addAction("New subfolder…", lambda: self._new_folder(folder_id)) - self._menu.addAction("Rename…", lambda: self._rename(folder_id)) - move = self._menu.addAction("Move to…", lambda: self._move_folder(folder_id)) - move.setEnabled(bool(self._service.folder_move_targets(folder_id))) - self._menu.addAction("Delete…", lambda: self._delete(folder_id)) - - @asyncSlot() - async def newFolder(self) -> None: - await self._new_folder(ROOT_ID) - - @asyncSlot() - async def _new_folder(self, parent_id: int) -> None: - result, dialog = await await_dialog( - FolderNameDialog, "New Folder", "", self, "Create" - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.create_folder(dialog.textValue(), parent_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _new_folder_from(self, package_ids: list[str]) -> None: - result, dialog = await await_dialog( - FolderNameDialog, "Create Folder from Selection", "", self, "Create" - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.create_folder_from(dialog.textValue(), package_ids) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _rename(self, folder_id: int) -> None: - current = self._service.state.folders[folder_id] - result, dialog = await await_dialog( - FolderNameDialog, "Rename Folder", current.name, self - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.rename_folder(folder_id, dialog.textValue()) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _move_mods(self, package_ids: list[str]) -> None: - allowed = frozenset(self._service.state.folders) - result, dialog = await await_dialog( - FolderPickerDialog, - self._service.tree(), - "Move to Folder", - allowed, - self, - ) - if result == QDialog.DialogCode.Accepted: - try: - if dialog.folder_id == ROOT_ID: - await self._service.ungroup(package_ids) - else: - await self._service.place(package_ids, dialog.folder_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _move_folder(self, folder_id: int) -> None: - targets = self._service.folder_move_targets(folder_id) - result, dialog = await await_dialog( - FolderPickerDialog, self._service.tree(), "Move Folder", targets, self - ) - if result == QDialog.DialogCode.Accepted: - try: - await self._service.move_folder(folder_id, dialog.folder_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _delete(self, folder_id: int) -> None: - folder = self._service.state.folders[folder_id] - result, _ = await await_dialog(DeleteFolderDialog, folder.name, self) - if result == QMessageBox.StandardButton.Yes: - try: - await self._service.delete_folder(folder_id) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _ungroup(self, package_ids: list[str]) -> None: - try: - await self._service.ungroup(package_ids) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _reset_rules(self, package_ids: list[str]) -> None: - try: - await self._service.reset_to_rules(package_ids) - except OrganizerError as exc: - await self._error(exc) - - @asyncSlot() - async def _update_selected(self, uuids: list[str]) -> None: - await update_mods(self._ctx, uuids, self) - - @asyncSlot() - async def _enable_many(self, uuids: list[str]) -> None: - await apply_activation(self._ctx, self, enable=uuids) - - @asyncSlot() - async def _disable_many(self, uuids: list[str]) -> None: - await apply_activation(self._ctx, self, disable=uuids) - - @Slot(QModelIndex, QModelIndex, result=bool) - def canDrop(self, source: QModelIndex, target: QModelIndex) -> bool: - src = self.model.node_at(source) - dst = self.model.node_at(target) - if src is None or dst is None or not dst.is_container or src is dst: - return False - if src.kind == "mod": - selected = ( - self.model.selected_nodes() if self.model.is_selected(source) else [src] - ) - nodes = [n for n in selected if n.kind == "mod"] - return bool(nodes) and all(n.leaf and n.leaf.package_id for n in nodes) - if src.kind == "folder": - return dst.folder_id in self._service.folder_move_targets(src.folder_id) - return False - - @asyncSlot(QModelIndex, QModelIndex) - async def dropOn(self, source: QModelIndex, target: QModelIndex) -> None: - if not self.canDrop(source, target): - return - src = self.model.node_at(source) - dst = self.model.node_at(target) - if src is None or dst is None: - return - try: - if src.kind == "folder": - await self._service.move_folder(src.folder_id, dst.folder_id) - else: - selected = ( - self.model.selected_nodes() - if self.model.is_selected(source) - else [src] - ) - pids = list( - dict.fromkeys( - n.leaf.package_id - for n in selected - if n.kind == "mod" and n.leaf and n.leaf.package_id - ) - ) - if dst.folder_id == ROOT_ID: - await self._service.ungroup(pids) - else: - await self._service.place(pids, dst.folder_id) - except OrganizerError as exc: - await self._error(exc) - - async def _error(self, exc: OrganizerError) -> None: - toast = self._app_ctx.toasts if self._app_ctx is not None else None - if toast is not None: - toast.error(str(exc)) - else: - logger.warning("organizer: {}", exc) diff --git a/src/pxmodrim/ui/plugins/steam_downloader/__init__.py b/src/pxmodrim/ui/plugins/steam_downloader/__init__.py deleted file mode 100644 index 3d72aa1..0000000 --- a/src/pxmodrim/ui/plugins/steam_downloader/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.steam_downloader.plugin import SteamDownloaderUiPlugin - -__all__ = ["SteamDownloaderUiPlugin"] diff --git a/src/pxmodrim/ui/plugins/steam_downloader/plugin.py b/src/pxmodrim/ui/plugins/steam_downloader/plugin.py deleted file mode 100644 index e5ee0d4..0000000 --- a/src/pxmodrim/ui/plugins/steam_downloader/plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, cast - -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.settings_section import PluginConfigSection - -if TYPE_CHECKING: - from PySide6.QtCore import QObject - - from pxmodrim.core.downloads.steam import SteamDownloader - from pxmodrim.ui.context import AppContext - - -class SteamSettingsSection(PluginConfigSection): - title = "Steam Workshop" - source = Path(__file__).with_name("SteamSettings.qml") - - -class SteamDownloaderUiPlugin(Plugin): - """Settings for the Steam downloader; imports nothing from it at runtime.""" - - name = "steam_downloader_ui" - dependencies: ClassVar[list[str]] = ["steam_downloader"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - downloader = cast("SteamDownloader", ctx.core.plugins.get("steam_downloader")) - - def section(parent: QObject) -> SteamSettingsSection: - return SteamSettingsSection(downloader.settings, parent) - - ctx.add_settings_section(section) diff --git a/src/pxmodrim/ui/plugins/steam_workshop/__init__.py b/src/pxmodrim/ui/plugins/steam_workshop/__init__.py deleted file mode 100644 index ab35a83..0000000 --- a/src/pxmodrim/ui/plugins/steam_workshop/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.steam_workshop.plugin import ( - ItemStatus, - SidebarSync, - SteamWorkshopUiPlugin, -) -from pxmodrim.ui.plugins.steam_workshop.view import SteamWorkshopViewPanel - -__all__ = [ - "ItemStatus", - "SidebarSync", - "SteamWorkshopUiPlugin", - "SteamWorkshopViewPanel", -] diff --git a/src/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py b/src/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py deleted file mode 100644 index 97af870..0000000 --- a/src/pxmodrim/ui/plugins/steam_workshop/bridge_obj.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING - -from PySide6.QtCore import QObject, Signal, Slot - -if TYPE_CHECKING: - from pxmodrim.ui.plugins.steam_workshop.plugin import SteamWorkshopUiPlugin - - -def _is_published_file_id(value: object) -> bool: - return isinstance(value, str) and value.isascii() and value.isdecimal() - - -class PxModRimBridge(QObject): - result_ready = Signal(str, str) - - def __init__( - self, plugin: SteamWorkshopUiPlugin, parent: QObject | None = None - ) -> None: - super().__init__(parent) - self._plugin = plugin - self._tasks: set[asyncio.Task[None]] = set() - - @Slot(str, str, str) - def call(self, request_id: str, method: str, payload: str) -> None: - task = asyncio.create_task(self._handle(request_id, method, payload)) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - async def _handle(self, request_id: str, method: str, payload: str) -> None: - try: - params = json.loads(payload) if payload else {} - - if method == "init_ready": - self._plugin.sync_all() - result = None - elif method == "toggle_download_checked": - mod_id = params["mod_id"] - if _is_published_file_id(mod_id): - self._plugin.toggle_download_checked( - mod_id, - params.get("title", ""), - params.get("checked", False), - ) - result = None - elif method == "batch_toggle_download_checked": - valid_items = [ - (mod_id, title) - for mod_id, title in zip( - params["mod_ids"], params["titles"], strict=True - ) - if _is_published_file_id(mod_id) - ] - if valid_items: - self._plugin.batch_toggle_download_checked( - [mod_id for mod_id, _title in valid_items], - [title for _mod_id, title in valid_items], - params.get("checked", False), - ) - result = None - elif method == "toggle_active": - mod_id = params["mod_id"] - if _is_published_file_id(mod_id): - self._plugin.toggle_active( - mod_id, - params.get("active", False), - ) - result = None - elif method == "batch_toggle_active": - mod_ids = [ - mod_id - for mod_id in params["mod_ids"] - if _is_published_file_id(mod_id) - ] - if mod_ids: - self._plugin.batch_toggle_active( - mod_ids, - params.get("active", False), - ) - result = None - elif method == "fetch_mod_deps": - mod_id = params["mod_id"] - if _is_published_file_id(mod_id): - raw = await asyncio.wait_for( - self._plugin.fetch_mod_deps(mod_id), - timeout=30, - ) - result = json.loads(raw) if raw else None - else: - result = None - else: - raise RuntimeError(f"Unknown method: {method}") - - self.result_ready.emit( - request_id, - json.dumps({"ok": True, "result": result}), - ) - except TimeoutError: - self.result_ready.emit( - request_id, - json.dumps({"ok": False, "error": "Timeout"}), - ) - except Exception as exc: - self.result_ready.emit( - request_id, - json.dumps({"ok": False, "error": str(exc)}), - ) diff --git a/src/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py b/src/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py deleted file mode 100644 index 8ed32df..0000000 --- a/src/pxmodrim/ui/plugins/steam_workshop/download_queue_model.py +++ /dev/null @@ -1,93 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from PySide6.QtCore import ( - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, -) - - -class DownloadQueueModel(QAbstractListModel): - _IdRole = Qt.ItemDataRole.UserRole + 1 - _TitleRole = Qt.ItemDataRole.UserRole + 2 - _DisplayRole = Qt.ItemDataRole.UserRole + 3 - _StatusRole = Qt.ItemDataRole.UserRole + 4 - _ProgressRole = Qt.ItemDataRole.UserRole + 5 - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._items: list[dict[str, Any]] = [] - - def rowCount( - self, - parent: QModelIndex | QPersistentModelIndex | None = None, - ) -> int: - return len(self._items) if parent is None or not parent.isValid() else 0 - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - if not index.isValid() or index.row() >= len(self._items): - return None - item = self._items[index.row()] - if role == self._IdRole: - return item["id"] - if role == self._TitleRole: - return item["title"] - if role == self._DisplayRole: - return item["display"] - if role == self._StatusRole: - return item["status"] - if role == self._ProgressRole: - return item["progress"] - return None - - def roleNames(self) -> dict[int, QByteArray]: - return { - self._IdRole: QByteArray(b"id"), - self._TitleRole: QByteArray(b"title"), - self._DisplayRole: QByteArray(b"display"), - self._StatusRole: QByteArray(b"status"), - self._ProgressRole: QByteArray(b"progress"), - } - - def sync_from( - self, - checked_ids: dict[str, str], - statuses: dict[str, str] | None = None, - ) -> None: - statuses = statuses or {} - self.beginResetModel() - self._items = [ - { - "id": mid, - "title": t or mid, - "display": f"{t} ({mid})" if t else mid, - "status": statuses.get(mid, ""), - "progress": 0.0, - } - for mid, t in checked_ids.items() - ] - self.endResetModel() - - def update_status( - self, mod_id: str, status: str, bytes_done: int = 0, bytes_total: int = 0 - ) -> None: - for row, item in enumerate(self._items): - if item["id"] == mod_id: - item["status"] = status - item["progress"] = bytes_done / bytes_total if bytes_total else 0.0 - idx = self.index(row) - self.dataChanged.emit(idx, idx, [self._StatusRole, self._ProgressRole]) - return - - @property - def count(self) -> int: - return len(self._items) diff --git a/src/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py b/src/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py deleted file mode 100644 index 153b74a..0000000 --- a/src/pxmodrim/ui/plugins/steam_workshop/download_sidebar.py +++ /dev/null @@ -1,83 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from PySide6.QtCore import QUrl, Signal, Slot -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QVBoxLayout, QWidget - -from pxmodrim.ui.plugins.steam_workshop.download_queue_model import ( - DownloadQueueModel, -) -from pxmodrim.ui.theme.constants import SIDEBAR_WIDTH -from pxmodrim.ui.theme.palette import PALETTE - -_QML_DIR = Path(__file__).parent -_DL_SIDEBAR_QML = _QML_DIR / "DownloadSidebar.qml" - - -class DownloadSidebar(QWidget): - download_requested = Signal() - item_removed = Signal(str) - stop_requested = Signal() - clear_requested = Signal() - - def __init__( - self, qml_engine: QQmlEngine | None = None, parent: QWidget | None = None - ) -> None: - """Initialize the download sidebar with queue model and QML view.""" - super().__init__(parent) - self.setObjectName("downloadSidebar") - self.setFixedWidth(SIDEBAR_WIDTH) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - self._model = DownloadQueueModel(self) - - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_2"])) - - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("downloadSidebar", self) - qml_ctx.setContextProperty("downloadQueueModel", self._model) - self._qml.setSource(QUrl.fromLocalFile(str(_DL_SIDEBAR_QML))) - - layout.addWidget(self._qml) - - @Slot() - def downloadRequested(self) -> None: - self.download_requested.emit() - - @Slot() - def stopRequested(self) -> None: - self.stop_requested.emit() - - @Slot(str) - def removeItem(self, mod_id: str) -> None: - self.item_removed.emit(mod_id) - - @Slot() - def clearQueue(self) -> None: - self.clear_requested.emit() - - def set_download_enabled(self, enabled: bool) -> None: - root = self._qml.rootObject() - if root is not None: - root.setProperty("downloadEnabled", enabled) - - def sync_from( - self, - checked_ids: dict[str, str], - statuses: dict[str, str] | None = None, - ) -> None: - self._model.sync_from(checked_ids, statuses) - - def update_status( - self, mod_id: str, status: str, bytes_done: int = 0, bytes_total: int = 0 - ) -> None: - self._model.update_status(mod_id, status, bytes_done, bytes_total) diff --git a/src/pxmodrim/ui/plugins/steam_workshop/plugin.py b/src/pxmodrim/ui/plugins/steam_workshop/plugin.py deleted file mode 100644 index 8fd04a9..0000000 --- a/src/pxmodrim/ui/plugins/steam_workshop/plugin.py +++ /dev/null @@ -1,282 +0,0 @@ -from __future__ import annotations - -import contextlib -from typing import TYPE_CHECKING, NamedTuple, cast - -import httpx -from loguru import logger - -from pxmodrim.core.events import Event -from pxmodrim.core.plugin import Plugin -from pxmodrim.ui.navigation import build_route - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult - from pxmodrim.core.downloads.steam import SteamDownloader - from pxmodrim.ui.context import AppContext - - -class SidebarSync(NamedTuple): - checked_ids: dict[str, str] - statuses: dict[str, str] - - -class ItemStatus(NamedTuple): - mod_id: str - status: str - bytes_done: int = 0 - bytes_total: int = 0 - - -class SteamWorkshopUiPlugin(Plugin): - name = "steamworkshop" - dependencies = ["steam_downloader"] - - badges_refresh_requested: Event[list[str]] - sidebar_sync_requested: Event[SidebarSync] - item_status_changed: Event[ItemStatus] - download_busy_changed: Event[bool] - uncheck_mod_requested: Event[str] - clear_checked_requested: Event[None] - active_refresh_requested: Event[list[str]] - - _svc: SteamDownloader - - def __init__(self) -> None: - self.badges_refresh_requested = Event() - self.sidebar_sync_requested = Event() - self.item_status_changed = Event() - self.download_busy_changed = Event() - self.uncheck_mod_requested = Event() - self.clear_checked_requested = Event() - self.active_refresh_requested = Event() - - self._core: CoreContext | None = None - self._app_ctx: AppContext | None = None - - self._installed_ids: set[str] = set() - self._active_ids: set[str] = set() - self._checked_ids: dict[str, str] = {} - self._download_statuses: dict[str, str] = {} - # The service is shared with MainWindow's update actions; only batches - # started from this view may drive the queue sidebar. - self._own_download = False - - # ── Plugin lifecycle ───────────────────────────────── - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - self._core = ctx.core - self._app_ctx = ctx - self._svc = cast("SteamDownloader", ctx.core.plugins.get("steam_downloader")) - from pxmodrim.ui.plugins.steam_workshop.view import SteamWorkshopViewPanel - - ctx.add_rail_view(SteamWorkshopViewPanel) - - ctx.core.mod_service.mods_changed.connect(self._on_mods_changed) - ctx.core.active_state_changed.connect(self._on_active_state_changed) - self._svc.download_item_status_changed.connect(self._on_item_status) - self._svc.download_finished.connect(self._on_download_finished) - self._svc.busy_changed.connect(self.download_busy_changed.emit) - - async def init(self, ctx: AppContext) -> None: - self._refresh_cached_ids() - - async def shutdown(self) -> None: - if self._core is None: - return - with contextlib.suppress(ValueError): - self._svc.download_item_status_changed.disconnect(self._on_item_status) - self._svc.download_finished.disconnect(self._on_download_finished) - self._svc.busy_changed.disconnect(self.download_busy_changed.emit) - self._core.mod_service.mods_changed.disconnect(self._on_mods_changed) - self._core.active_state_changed.disconnect( - self._on_active_state_changed - ) - - # ── JS action handlers (called by action_handler) ──── - - def toggle_download_checked(self, mod_id: str, title: str, checked: bool) -> None: - if checked: - self._checked_ids[mod_id] = title - else: - self._checked_ids.pop(mod_id, None) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - def batch_toggle_download_checked( - self, mod_ids: list[str], titles: list[str], checked: bool - ) -> None: - for mod_id, title in zip(mod_ids, titles, strict=True): - if checked: - self._checked_ids[mod_id] = title - else: - self._checked_ids.pop(mod_id, None) - if mod_ids: - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - def toggle_active(self, mod_id: str, active: bool) -> None: - if self._core is None: - return - uuid = next( - ( - u - for u, m in self._core.all_mods.items() - if m.published_file_id == mod_id - ), - None, - ) - if uuid is None: - return - current = set(self._core.active_uuids) - if active: - current.add(uuid) - else: - current.discard(uuid) - self._core.set_active(list(current)) - - def batch_toggle_active(self, mod_ids: list[str], active: bool) -> None: - if self._core is None: - return - pub_to_uuid = { - m.published_file_id: u - for u, m in self._core.all_mods.items() - if m.published_file_id - } - current = set(self._core.active_uuids) - for mid in mod_ids: - uuid = pub_to_uuid.get(mid) - if uuid is None: - continue - if active: - current.add(uuid) - else: - current.discard(uuid) - self._core.set_active(list(current)) - - # ── Sidebar callbacks (called by view) ─────────────── - - async def request_download(self) -> None: - ids = list(self._checked_ids.keys()) - if not ids: - logger.debug("[steam] download requested with empty queue") - return - if self._core is None or not self._core.config.paths.local: - self._svc.status_message_changed.emit("Local mods path is not configured") - return - if self._svc.is_downloading: - self._svc.status_message_changed.emit("A download is already running") - return - logger.debug("[steam] download requested: {}", ids) - - self._own_download = True - if self._app_ctx is not None: - self._app_ctx.navigate(build_route("downloads")) - try: - for mod_id in ids: - self._download_statuses[mod_id] = "queued" - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - await self._svc.download_mods(ids) - finally: - self._own_download = False - - def stop_download(self) -> None: - logger.info("[steam] download stop requested") - self._svc.cancel() - - def remove_item(self, mod_id: str) -> None: - logger.debug("[steam] download item removed: %s", mod_id) - self._checked_ids.pop(mod_id, None) - self._download_statuses.pop(mod_id, None) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - self.uncheck_mod_requested.emit(mod_id) - - def clear_queue(self) -> None: - logger.info("[steam] download queue cleared (%d items)", len(self._checked_ids)) - self._checked_ids.clear() - self._download_statuses.clear() - self.sidebar_sync_requested.emit(SidebarSync({}, {})) - self.clear_checked_requested.emit(None) - - def sync_all(self) -> None: - self._refresh_cached_ids() - self.badges_refresh_requested.emit(list(self._installed_ids)) - self.active_refresh_requested.emit(list(self._active_ids)) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - # ── Service event handlers ─────────────────────────── - - def _on_active_state_changed(self, _: tuple[str, ...]) -> None: - self._refresh_cached_ids() - self.badges_refresh_requested.emit(list(self._installed_ids)) - self.active_refresh_requested.emit(list(self._active_ids)) - - def _on_mods_changed(self, _: None) -> None: - self._refresh_cached_ids() - self.badges_refresh_requested.emit(list(self._installed_ids)) - self.active_refresh_requested.emit(list(self._active_ids)) - - def _on_item_status(self, item: DownloadItemStatus) -> None: - if not self._own_download: - return - self._download_statuses[item.mod_id] = item.status - self.item_status_changed.emit( - ItemStatus(item.mod_id, item.status, item.bytes_done, item.bytes_total) - ) - - def _on_download_finished(self, result: DownloadResult) -> None: - if not self._own_download: - return - logger.info( - "[steam] download finished: {} ok, {} failed", - len(result.succeeded), - len(result.failed), - ) - for mid in result.succeeded: - self._checked_ids.pop(mid, None) - self._download_statuses.pop(mid, None) - for mod_id, status in tuple(self._download_statuses.items()): - if status in {"queued", "downloading"}: - self._download_statuses.pop(mod_id) - self.sidebar_sync_requested.emit( - SidebarSync(dict(self._checked_ids), dict(self._download_statuses)) - ) - - # ── Internal helpers ───────────────────────────────── - - def _refresh_cached_ids(self) -> None: - if self._core is None: - return - self._installed_ids = { - m.published_file_id - for m in self._core.all_mods.values() - if m.published_file_id - } - active_uuids = set(self._core.active_uuids) - self._active_ids = { - m.published_file_id - for m in self._core.all_mods.values() - if m.published_file_id and m.uuid in active_uuids - } - - async def fetch_mod_deps(self, mod_id: str) -> str | None: - url = f"https://deps.modrim.pyxiion.ru/deps?id={mod_id}" - async with httpx.AsyncClient(timeout=30) as client: - resp = await client.get(url, headers={"User-Agent": "PxModRim/1.0"}) - if resp.status_code != 200: - logger.warning( - "[steam] fetch_mod_deps HTTP {} for {}", - resp.status_code, - mod_id, - ) - return None - return resp.text diff --git a/src/pxmodrim/ui/plugins/steam_workshop/view.py b/src/pxmodrim/ui/plugins/steam_workshop/view.py deleted file mode 100644 index 051e86a..0000000 --- a/src/pxmodrim/ui/plugins/steam_workshop/view.py +++ /dev/null @@ -1,217 +0,0 @@ -from __future__ import annotations - -import json -from importlib.resources import files as resource_files -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from loguru import logger -from PySide6.QtCore import QObject, QUrl, Slot -from PySide6.QtGui import QColor -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QHBoxLayout, QWidget -from qasync import asyncSlot - -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.plugins.steam_workshop.bridge_obj import PxModRimBridge -from pxmodrim.ui.plugins.steam_workshop.download_sidebar import DownloadSidebar -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - - from pxmodrim.ui.context import AppContext - from pxmodrim.ui.plugins.steam_workshop.plugin import ( - ItemStatus, - SidebarSync, - SteamWorkshopUiPlugin, - ) - -_WORKSHOP_URL = "https://steamcommunity.com/workshop/browse/?appid=294100" - -_QML_DIR = Path(__file__).parent -_STEAM_WORKSHOP_QML = _QML_DIR / "SteamWorkshop.qml" - -_INJECT_JS = ( - resource_files("pxmodrim.ui.plugins.steam_workshop") / "inject.js" -).read_text(encoding="utf-8") - -_INJECT_CSS = ( - resource_files("pxmodrim.ui.plugins.steam_workshop") / "inject.css" -).read_text(encoding="utf-8") - -_INJECTION = f"var CSS_STYLES = {json.dumps(_INJECT_CSS)};\n{_INJECT_JS}" - - -class SteamWorkshopViewPanel(BaseViewPanel): - view_id = "steam_workshop" - icon_name = "steam" - label = "Steam Workshop" - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - - self._plugin: SteamWorkshopUiPlugin | None = None - self._initialized = False - - content = QWidget(self) - h_layout = QHBoxLayout(content) - h_layout.setContentsMargins(0, 0, 0, 0) - h_layout.setSpacing(0) - - self._download_sidebar = DownloadSidebar(self._qml_engine, content) - h_layout.addWidget(self._download_sidebar) - - self._qml = QQuickWidget(self._qml_engine, content) # pyright: ignore[reportCallIssue, reportArgumentType] - self._qml.setObjectName("workshopView") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) - h_layout.addWidget(self._qml, stretch=1) - - self._root.addWidget(content, stretch=1) - - # ── QML object access ──────────────────────────────── - - def _web(self) -> QObject | None: - if not self._initialized: - return None - root = self._qml.rootObject() - if root is None: - return None - return root.findChild(QObject, "workshopWeb") - - def _run_js(self, code: str) -> None: - web = self._web() - if web is not None: - web.runJavaScript(code, 0) # pyright: ignore[reportAttributeAccessIssue] - - # ── Initialization ─────────────────────────────────── - - def _ensure_initialized(self) -> None: - if self._initialized: - return - self._initialized = True - - self._plugin = cast( - "SteamWorkshopUiPlugin | None", - self._app_ctx.plugins.get("steamworkshop") if self._app_ctx else None, - ) - if self._plugin is not None: - self._download_sidebar.download_requested.connect( - self._on_download_requested - ) - self._download_sidebar.stop_requested.connect(self._plugin.stop_download) - self._download_sidebar.item_removed.connect(self._plugin.remove_item) - self._download_sidebar.clear_requested.connect(self._plugin.clear_queue) - - self._plugin.badges_refresh_requested.connect(self._push_badges_to_js) - self._plugin.active_refresh_requested.connect(self._push_active_to_js) - self._plugin.sidebar_sync_requested.connect(self._on_sidebar_sync) - self._plugin.item_status_changed.connect(self._on_item_status_changed) - self._plugin.download_busy_changed.connect( - lambda busy: self._download_sidebar.set_download_enabled(not busy) - ) - self._plugin.uncheck_mod_requested.connect(self._push_uncheck_to_js) - self._plugin.clear_checked_requested.connect(self._push_clear_checked_to_js) - - self._bridge: PxModRimBridge | None = None - if self._plugin is not None: - self._bridge = PxModRimBridge(self._plugin, self) - - qml_ctx = self._qml.rootContext() - qml_ctx.setContextProperty("steamWorkshopPanel", self) - qml_ctx.setContextProperty("_bridge", self._bridge) - qml_ctx.setContextProperty("_injectCode", _INJECTION) - - self._qml.setSource(QUrl.fromLocalFile(str(_STEAM_WORKSHOP_QML))) - - # ── QML-invokable slots ────────────────────────────── - - @Slot() - def navigateHome(self) -> None: - web = self._web() - if web is not None: - web.setProperty("url", QUrl(_WORKSHOP_URL)) - - @Slot(str) - def navigateToUrl(self, text: str) -> None: - if not text: - return - url = QUrl.fromUserInput(text) - if url.scheme() == "http": - url.setScheme("https") - if not url.isValid(): - logger.warning("[steam] invalid URL: %s", text) - return - logger.debug("[steam] navigating to URL: %s", url.toString()) - web = self._web() - if web is not None: - web.setProperty("url", url) - - # ── Public API ─────────────────────────────────────── - - def preload(self) -> None: - self._ensure_initialized() - - # ── Plugin event handlers ──────────────────────────── - - def _push_badges_to_js(self, ids: list[str]) -> None: - js = json.dumps(ids) - self._run_js(f"if (window.__pxmSetInstalled) window.__pxmSetInstalled({js})") - - def _push_active_to_js(self, ids: list[str]) -> None: - js = json.dumps(ids) - self._run_js(f"if (window.__pxmSetActive) window.__pxmSetActive({js})") - - def _push_uncheck_to_js(self, mod_id: str) -> None: - self._run_js(f"window.__pxmUncheckMod({json.dumps(mod_id)});") - - def _push_clear_checked_to_js(self, _: None) -> None: - self._run_js("window.__pxmClearChecked();") - - def _on_sidebar_sync(self, sync: SidebarSync) -> None: - self._download_sidebar.sync_from(sync.checked_ids, sync.statuses) - - def _on_item_status_changed(self, status: ItemStatus) -> None: - self._download_sidebar.update_status( - status.mod_id, status.status, status.bytes_done, status.bytes_total - ) - - # ── Sidebar signal handler ─────────────────────────── - - @asyncSlot() - async def _on_download_requested(self) -> None: - if self._plugin is not None: - await self._plugin.request_download() - - # ── Lifecycle ──────────────────────────────────────── - - def showEvent(self, event: object) -> None: - logger.debug("[steam] view shown") - super().showEvent(event) # type: ignore[attr-defined] - self._ensure_initialized() - - def teardown(self) -> None: - logger.debug("[steam] view teardown") - - if not self._initialized: - return - - web = self._web() - if web is not None: - web.stop() # pyright: ignore[reportAttributeAccessIssue] - web.setProperty("url", QUrl("about:blank")) - - self._qml.setSource(QUrl()) - self._qml.setParent(None) - self._qml.deleteLater() - - self._plugin = None - self._initialized = False diff --git a/src/pxmodrim/ui/plugins/workshop/__init__.py b/src/pxmodrim/ui/plugins/workshop/__init__.py deleted file mode 100644 index 3bb8167..0000000 --- a/src/pxmodrim/ui/plugins/workshop/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.plugins.workshop.plugin import WorkshopUiPlugin -from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel - -__all__ = ["WorkshopUiPlugin", "WorkshopViewPanel"] diff --git a/src/pxmodrim/ui/plugins/workshop/details.py b/src/pxmodrim/ui/plugins/workshop/details.py deleted file mode 100644 index 0d15799..0000000 --- a/src/pxmodrim/ui/plugins/workshop/details.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING - -import msgspec - -from pxmodrim.core.workshop import CatalogCollection, CatalogError, CatalogMod - -if TYPE_CHECKING: - from pxmodrim.core.workshop import DownloadPlan, WorkshopCatalog - - -class Detail(msgspec.Struct, frozen=True): - item: CatalogMod | CatalogCollection - members: list[CatalogMod | CatalogCollection] - warning: str - complete: bool - - -async def fetch_detail(catalog: WorkshopCatalog, item_id: str, kind: str) -> Detail: - if kind == "collection": - return await _collection_detail(catalog, item_id) - return await _mod_detail(catalog, item_id) - - -async def _collection_detail(catalog: WorkshopCatalog, item_id: str) -> Detail: - detail = await catalog.collection(item_id) - if detail is None: - raise CatalogError("This collection is no longer available.", 404) - warning = "" - if not detail.is_complete: - warning = ( - "This collection is incomplete. Some members are unavailable or not listed." - ) - if detail.unavailable_ids: - warning += " Unavailable: " + ", ".join(detail.unavailable_ids) - return Detail(detail.collection, list(detail.members), warning, detail.is_complete) - - -async def _mod_detail(catalog: WorkshopCatalog, item_id: str) -> Detail: - mod = await catalog.mod(item_id) - if mod is None: - raise CatalogError("This mod is no longer available.", 404) - dependencies = await asyncio.gather(*(catalog.mod(pid) for pid in mod.dependencies)) - missing = [ - pid - for pid, item in zip(mod.dependencies, dependencies, strict=True) - if item is None - ] - warning = "Unavailable dependencies: " + ", ".join(missing) if missing else "" - found = [item for item in dependencies if item is not None] - return Detail(mod, list(found), warning, not missing) - - -def incomplete_plan_notice(plan: DownloadPlan) -> str: - parts = ["The download plan is incomplete."] - if plan.unavailable_ids: - parts.append("Unavailable: " + ", ".join(plan.unavailable_ids)) - if plan.incomplete_collection_ids: - parts.append( - "Incomplete collections: " + ", ".join(plan.incomplete_collection_ids) - ) - return " ".join(parts) - - -def queued_notice(count: int) -> str: - return ( - f"Queued {count} mod{'s' if count != 1 else ''}. Progress is shown " - "in the header and results in Downloads. Mods are not activated." - ) diff --git a/src/pxmodrim/ui/plugins/workshop/installed.py b/src/pxmodrim/ui/plugins/workshop/installed.py deleted file mode 100644 index a8023a9..0000000 --- a/src/pxmodrim/ui/plugins/workshop/installed.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pxmodrim.core.workshop import CatalogMod - -PAGE = 48 - - -class InstalledList: - """Installed Workshop mods with a text filter and incremental paging.""" - - def __init__(self) -> None: - self._mods: list[CatalogMod] = [] - self._matches: list[CatalogMod] = [] - self._query = "" - self._shown = PAGE - - @property - def mods(self) -> list[CatalogMod]: - return self._mods - - @property - def loaded(self) -> bool: - return bool(self._mods) - - @property - def total(self) -> int: - return len(self._matches) - - @property - def has_more(self) -> bool: - return self._shown < len(self._matches) - - def replace(self, mods: list[CatalogMod]) -> None: - self._mods = mods - self._rematch() - - def search(self, query: str) -> None: - self._query = query - self._shown = PAGE - self._rematch() - - def reset_paging(self) -> None: - self._shown = PAGE - - def visible(self) -> list[CatalogMod]: - return self._matches[: self._shown] - - def next_page(self) -> list[CatalogMod]: - start = self._shown - self._shown = min(start + PAGE, len(self._matches)) - return self._matches[start : self._shown] - - def _rematch(self) -> None: - needle = self._query.casefold() - self._matches = [mod for mod in self._mods if needle in _haystack(mod)] - - -def _haystack(mod: CatalogMod) -> str: - return " ".join((mod.title, mod.author.name or "", mod.id, *mod.tags)).casefold() diff --git a/src/pxmodrim/ui/plugins/workshop/models.py b/src/pxmodrim/ui/plugins/workshop/models.py deleted file mode 100644 index dd094dd..0000000 --- a/src/pxmodrim/ui/plugins/workshop/models.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import annotations - -import re -from collections.abc import Callable, Sequence -from typing import Any -from urllib.parse import urlsplit - -from PySide6.QtCore import ( - Property, - QAbstractListModel, - QByteArray, - QModelIndex, - QObject, - QPersistentModelIndex, - Qt, - Signal, -) - -from pxmodrim.core.workshop import CatalogCollection, CatalogMod, InstallState -from pxmodrim.ui.panels.mod_info_data import description_to_html - - -def safe_url(value: str) -> str: - if any(char.isspace() or ord(char) < 32 for char in value): - return "" - try: - parsed = urlsplit(value) - except ValueError: - return "" - return value if parsed.scheme.lower() in {"http", "https"} and parsed.netloc else "" - - -_BBCODE_TAG = re.compile(r"\[/?[a-zA-Z*][^\]]*\]") -_BBCODE_MEDIA = re.compile( - r"\[(img|video|previewimage)[^\]]*\].*?\[/\1\]", re.IGNORECASE | re.DOTALL -) -_SUMMARY_LENGTH = 140 - - -def summarize(text: str, description_format: str = "bbcode") -> str: - """First sentence-ish of a description as plain text, for card blurbs.""" - if description_format == "bbcode": - text = _BBCODE_TAG.sub(" ", _BBCODE_MEDIA.sub(" ", text)) - text = " ".join(text.split()) - if len(text) <= _SUMMARY_LENGTH: - return text - cut = text[:_SUMMARY_LENGTH].rsplit(" ", 1)[0].rstrip(".,;:-–— ") - return f"{cut}…" - - -def _compat_label( - supported: set[str], game_version: str, incompatible: bool, no_common: bool -) -> str: - if not game_version: - return "" - if no_common: - return "No common version" - if incompatible: - return f"Not for {game_version}" - if not supported: - return "Version not stated" - return f"Works with {game_version}" - - -def item_row( - item: CatalogMod | CatalogCollection, - install_state: Callable[[CatalogMod], InstallState], - game_version: str, - *, - detail: bool = False, -) -> dict[str, Any]: - versions = set(item.supported_versions) - if isinstance(item, CatalogCollection): - versions.update( - tag for tag in item.tags if re.fullmatch(r"\d+\.\d+(?:\.\d+)?", tag) - ) - incompatible = bool(game_version and versions and game_version not in versions) - file_size, votes, member_count = "Unknown", "Unrated", 0 - votes_up = votes_down = "0" - if isinstance(item, CatalogMod): - state = install_state(item) - incompatible = incompatible or item.incompatible - source_label = "Steam Workshop" - action_label = { - "missing": "Download", - "installed": "Installed", - "outdated": "Update", - }[state] - if item.file_size is not None: - file_size = f"{int(item.file_size) / 1048576:.1f} MB" - if item.votes is not None: - votes_up, votes_down = f"{item.votes.up:,}", f"{item.votes.down:,}" - total = item.votes.up + item.votes.down - if total: - votes = f"{100 * item.votes.up / total:.0f}%" - else: - state = "missing" - source_label = ( - "PxModRim pick" if item.source == "picked" else "Steam collection" - ) - action_label = "Download all" - member_count = item.member_count - if item.total_size is not None: - file_size = f"{int(item.total_size) / 1048576:.1f} MB" - no_common = ( - isinstance(item, CatalogCollection) and item.no_common_version and not versions - ) - incompatible = incompatible or no_common - return { - "itemId": item.id, - "kind": item.kind, - "title": item.title, - "author": item.author.name or item.author.id or "Unknown author", - "previewUrl": safe_url( - item.preview_url - or next((p.url for p in item.previews if p.type == "image"), "") - ), - "collage": [ - url - for url in ( - safe_url(u) - for u in ( - item.member_previews if isinstance(item, CatalogCollection) else [] - ) - ) - if url - ], - "sourceLabel": source_label, - "versions": ", ".join( - sorted(versions, key=lambda version: tuple(map(int, version.split(".")))) - ) - or "Not specified", - "tags": " · ".join(tag for tag in item.tags if tag not in versions), - "state": state, - "active": False, - "queued": False, - "favourite": False, - "stateLabel": { - "missing": "Not installed", - "installed": "Installed", - "outdated": "Update available", - }[state], - "actionLabel": action_label, - "incompatible": incompatible, - "memberCount": member_count, - "description": description_to_html(item.description, item.description_format) - if detail - else "", - "workshopUrl": safe_url(item.workshop_url or ""), - "fileSize": file_size, - "votes": votes, - "votesUp": votes_up, - "votesDown": votes_down, - "summary": summarize(item.description, item.description_format), - "compatLabel": _compat_label(versions, game_version, incompatible, no_common), - } - - -class CatalogListModel(QAbstractListModel): - changed = Signal() - _roles = ( - "itemId", - "kind", - "title", - "author", - "previewUrl", - "collage", - "sourceLabel", - "versions", - "tags", - "state", - "active", - "stateLabel", - "actionLabel", - "incompatible", - "memberCount", - "description", - "workshopUrl", - "fileSize", - "votes", - "votesUp", - "votesDown", - "queued", - "favourite", - "summary", - "compatLabel", - ) - - def __init__(self, parent: QObject) -> None: - super().__init__(parent) - self._rows: list[dict[str, Any]] = [] - self._cursor: str | None = None - self._total = 0 - - def set_page( - self, - rows: Sequence[dict[str, Any]], - total: int, - cursor: str | None, - *, - append: bool = False, - ) -> None: - if append: - known = {row["itemId"] for row in self._rows} - additions = [] - for row in rows: - if row["itemId"] not in known: - additions.append(row) - known.add(row["itemId"]) - if additions: - self.beginInsertRows( - QModelIndex(), len(self._rows), len(self._rows) + len(additions) - 1 - ) - self._rows.extend(additions) - self.endInsertRows() - elif [row["itemId"] for row in rows] == [row["itemId"] for row in self._rows]: - # A background refresh of the same page: resetting would rebuild every - # delegate and reload its thumbnail, so only changed rows are updated. - for index, row in enumerate(rows): - if row != self._rows[index]: - self._rows[index] = row - self.dataChanged.emit(self.index(index), self.index(index)) - else: - self.beginResetModel() - self._rows = list(rows) - self.endResetModel() - self._total, self._cursor = total, cursor - self.changed.emit() - - def refresh_rows(self, convert: Callable[[dict[str, Any]], dict[str, Any]]) -> None: - self._rows = [convert(row) for row in self._rows] - if self._rows: - self.dataChanged.emit(self.index(0), self.index(len(self._rows) - 1)) - - def rowCount( - self, parent: QModelIndex | QPersistentModelIndex | None = None - ) -> int: - return len(self._rows) if parent is None or not parent.isValid() else 0 - - def data( - self, - index: QModelIndex | QPersistentModelIndex, - role: int = Qt.ItemDataRole.DisplayRole, - ) -> Any: - offset = role - int(Qt.ItemDataRole.UserRole) - 1 - if ( - not index.isValid() - or not 0 <= index.row() < len(self._rows) - or not 0 <= offset < len(self._roles) - ): - return None - return self._rows[index.row()][self._roles[offset]] - - def roleNames(self) -> dict[int, QByteArray]: - return { - int(Qt.ItemDataRole.UserRole) + offset + 1: QByteArray(name.encode()) - for offset, name in enumerate(self._roles) - } - - @property - def cursor(self) -> str | None: - return self._cursor - - @Property(bool, notify=changed) # type: ignore[arg-type] - def hasMore(self) -> bool: - return self._cursor is not None - - @Property(int, notify=changed) # type: ignore[arg-type] - def count(self) -> int: - return len(self._rows) - - @Property(int, notify=changed) # type: ignore[arg-type] - def total(self) -> int: - return self._total diff --git a/src/pxmodrim/ui/plugins/workshop/plugin.py b/src/pxmodrim/ui/plugins/workshop/plugin.py deleted file mode 100644 index 2d29648..0000000 --- a/src/pxmodrim/ui/plugins/workshop/plugin.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, cast - -from pxmodrim.core.plugin import Plugin -from pxmodrim.core.workshop import WorkshopCatalog -from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel -from pxmodrim.ui.settings_section import PluginConfigSection - -if TYPE_CHECKING: - from PySide6.QtCore import QObject - - from pxmodrim.ui.context import AppContext - - -class CatalogSettingsSection(PluginConfigSection): - title = "Workshop catalog" - source = Path(__file__).with_name("CatalogSettings.qml") - - -class WorkshopUiPlugin(Plugin): - name = "workshop_ui" - dependencies: ClassVar[list[str]] = ["workshop_catalog", "downloads"] - - def setup(self, ctx: AppContext) -> None: # type: ignore[override] - ctx.add_rail_view(WorkshopViewPanel) - catalog = cast(WorkshopCatalog, ctx.core.plugins.get("workshop_catalog")) - - def section(parent: QObject) -> CatalogSettingsSection: - return CatalogSettingsSection(catalog.settings, parent) - - ctx.add_settings_section(section) diff --git a/src/pxmodrim/ui/plugins/workshop/view.py b/src/pxmodrim/ui/plugins/workshop/view.py deleted file mode 100644 index 03dfad8..0000000 --- a/src/pxmodrim/ui/plugins/workshop/view.py +++ /dev/null @@ -1,867 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable -from hashlib import sha256 -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -import msgspec -from loguru import logger -from PySide6.QtCore import Property, QUrl, Signal, Slot -from PySide6.QtGui import QColor, QDesktopServices -from PySide6.QtQuickWidgets import QQuickWidget -from qasync import asyncSlot - -from pxmodrim.core.downloads import download_manager -from pxmodrim.core.workshop import ( - CatalogCollection, - CatalogError, - CatalogMod, - CatalogPage, - CatalogQuery, - Discover, - DownloadPlan, - WorkshopCatalog, -) -from pxmodrim.core.workshop.catalog import FRESH_SECONDS -from pxmodrim.ui.components.mod_activation import apply_activation -from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route -from pxmodrim.ui.plugins.workshop.details import ( - Detail, - fetch_detail, - incomplete_plan_notice, - queued_notice, -) -from pxmodrim.ui.plugins.workshop.installed import InstalledList -from pxmodrim.ui.plugins.workshop.models import CatalogListModel, item_row, safe_url -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.views.base import BaseViewPanel - -if TYPE_CHECKING: - from PySide6.QtQml import QQmlEngine - from PySide6.QtWidgets import QWidget - - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.context import AppContext - -_FAILURES = (CatalogError, RuntimeError, ValueError) -_TABS = ("Discover", "Mods", "Collections", "Favourites", "Installed") - - -def _message(exc: Exception) -> str: - return exc.message if isinstance(exc, CatalogError) else str(exc) - - -class WorkshopViewPanel(BaseViewPanel): - view_id = "workshop" - icon_name = "steam" - label = "Workshop" - changed = Signal() - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - *, - catalog: WorkshopCatalog | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - self._catalog = catalog or cast( - WorkshopCatalog, ctx.plugins.get("workshop_catalog") - ) - self._downloads = download_manager(ctx) - self.mods_model = CatalogListModel(self) - self.collections_model = CatalogListModel(self) - self.members_model = CatalogListModel(self) - self._tab = "Discover" - self._criteria = CatalogQuery(version=self._game_version or None) - self._busy = False - self._error = "" - self._notice = "" - self._generation = 0 - self._torn_down = False - self._retry: Callable[[], Awaitable[None]] | None = None - self._detail: dict[str, Any] = {} - self._detail_data: Detail | None = None - self._installed = InstalledList() - self._installed_dirty = False - self._route_tasks: set[asyncio.Future[None]] = set() - self._current: tuple[str, str] | None = None - self._trail: list[tuple[str, str, str]] = [] - self._update_count = 0 - self._pending: DownloadPlan | None = None - self._planning = 0 - self._preparing: set[str] = set() - self._collection_mods: dict[str, frozenset[str]] = {} - self._items: dict[str, CatalogMod | CatalogCollection] = {} - self._known_tags: set[str] = set() - self._installed_ids: set[str] = set() - self._active_ids: set[str] = set() - self._pack_ids: frozenset[str] = frozenset() - self._refresh_mod_ids() - self._qml = QQuickWidget(qml_engine, self) # type: ignore[arg-type] - self._qml.setObjectName("workshopView") - self._qml.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - self._qml.setClearColor(QColor(PALETTE["ELEVATE_0"])) - qml_ctx = self._qml.rootContext() - for name, value in ( - ("workshopPanel", self), - ("catalogMods", self.mods_model), - ("catalogCollections", self.collections_model), - ("catalogMembers", self.members_model), - ): - qml_ctx.setContextProperty(name, value) - self._qml.setSource( - QUrl.fromLocalFile(str(Path(__file__).with_name("Workshop.qml"))) - ) - self._root.addWidget(self._qml, 1) - self._downloads.busy_changed.connect(self._on_queue_changed) - self._catalog.queue_changed.connect(self._on_queue_changed) - self._catalog.catalog_url_changed.connect(self._on_url_changed) - self._catalog.installed_changed.connect(self._on_installed_changed) - self._favourites = self._catalog.favourites - self._favourites.changed.connect(self._on_favourites_changed) - self._ctx.active_state_changed.connect(self._on_active_state_changed) - self.destroyed.connect(lambda: self.teardown()) - - def teardown(self) -> None: - if self._torn_down: - return - self._torn_down = True - self._generation += 1 - self._retry = None - self._downloads.busy_changed.disconnect(self._on_queue_changed) - self._catalog.queue_changed.disconnect(self._on_queue_changed) - self._catalog.catalog_url_changed.disconnect(self._on_url_changed) - self._catalog.installed_changed.disconnect(self._on_installed_changed) - self._favourites.changed.disconnect(self._on_favourites_changed) - self._ctx.active_state_changed.disconnect(self._on_active_state_changed) - - @property - def _game_version(self) -> str: - version = self._ctx.target_version - return "" if version == "Unknown" else version - - # -- rows ------------------------------------------------------------- - - def _refresh_mod_ids(self) -> None: - mods = self._ctx.all_mods - self._installed_ids = { - published_id - for mod in mods.values() - if (published_id := mod.published_file_id) is not None - } - self._active_ids = { - published_id - for uuid in self._ctx.active_uuids - if uuid in mods - and (published_id := mods[uuid].published_file_id) is not None - } - - def _row( - self, item: CatalogMod | CatalogCollection, *, detail: bool = False - ) -> dict[str, Any]: - self._items[item.id] = item - self._known_tags.update(set(item.tags) - set(item.supported_versions)) - row = item_row( - item, self._catalog.install_state, self._game_version, detail=detail - ) - row["active"] = isinstance(item, CatalogMod) and item.id in self._active_ids - if isinstance(item, CatalogCollection): - if not detail: - installed_count = sum( - member_id in self._installed_ids for member_id in item.member_ids - ) - if item.member_ids and installed_count == len(item.member_ids): - row["state"], row["stateLabel"] = "installed", "Installed" - row["actionLabel"] = "Installed" - elif installed_count: - row["actionLabel"] = "Complete download" - if item.id in self._favourites: - row["favourite"] = True - self._favourites.remember(item) - if isinstance(item, CatalogCollection) or row["state"] != "installed": - self._mark_progress(item, row) - return row - - def _mark_progress( - self, item: CatalogMod | CatalogCollection, row: dict[str, Any] - ) -> None: - if item.id in self._preparing: - row["queued"], row["actionLabel"] = True, "Preparing…" - return - if isinstance(item, CatalogMod): - ids: frozenset[str] = frozenset((item.id,)) - else: - ids = frozenset(item.member_ids) | self._collection_mods.get( - item.id, frozenset() - ) - if self._detail_data is not None and self._detail_data.item.id == item.id: - ids |= self._pack_ids - if not ids.isdisjoint(self._downloads.active_ids): - row["queued"], row["actionLabel"] = True, "Downloading" - elif not ids.isdisjoint(self._catalog.queued_ids): - row["queued"], row["actionLabel"] = True, "Queued" - - def _rows(self, items: Iterable[CatalogMod | CatalogCollection]) -> list[Any]: - self._refresh_mod_ids() - return [self._row(item) for item in items] - - def _detail_row(self, detail: Detail) -> dict[str, Any]: - row = self._row(detail.item, detail=True) - row["complete"], row["warning"] = detail.complete, detail.warning - if isinstance(detail.item, CatalogCollection): - if not row["previewUrl"] and not row["collage"]: - for member in detail.members: - if not isinstance(member, CatalogMod): - continue - preview = safe_url( - member.preview_url - or next( - (p.url for p in member.previews if p.type == "image"), "" - ) - ) - if preview: - row["collage"].append(preview) - if len(row["collage"]) == 4: - break - started = any( - isinstance(member, CatalogMod) - and self._catalog.install_state(member) != "missing" - for member in detail.members - ) - if not row["queued"]: - row["actionLabel"] = "Complete download" if started else "Download all" - copies = self._pack_copies() - if copies is not None: - active = set(self._ctx.active_uuids) - row["state"], row["stateLabel"] = "installed", "Installed" - if not row["queued"]: - row["actionLabel"] = "Installed" - row["active"] = all(uuid in active for uuid in copies) - return row - - def _refresh_rows(self) -> None: - for model in (self.mods_model, self.collections_model, self.members_model): - model.refresh_rows(lambda row: self._row(self._items[row["itemId"]])) - if self._detail_data is not None: - self._detail = self._detail_row(self._detail_data) - - def _clear_detail(self) -> None: - self._detail, self._detail_data = {}, None - self._current = None - self._trail.clear() - self._pack_ids = frozenset() - - def _pack_copies(self) -> list[str] | None: - """One installed copy per collection mod, or None until all are downloaded.""" - if self._torn_down or not self._pack_ids: - return None - installed: dict[str, list[str]] = {} - for uuid, mod in self._ctx.all_mods.items(): - published_id = mod.published_file_id - if published_id is not None and published_id in self._pack_ids: - installed.setdefault(published_id, []).append(uuid) - if len(installed) < len(self._pack_ids): - return None - active = set(self._ctx.active_uuids) - return [ - next((uuid for uuid in uuids if uuid in active), uuids[0]) - for uuids in installed.values() - ] - - async def _load_pack(self) -> None: - """Resolve the collection's mods and dependencies for the activation buttons.""" - detail, generation = self._detail_data, self._generation - if detail is None: - return - try: - plan = await self._catalog.plan([], [detail.item.id]) - except _FAILURES as exc: - logger.warning( - "[workshop] cannot resolve collection mods: {}", _message(exc) - ) - return - if generation != self._generation: - return - self._pack_ids = ( - frozenset((*plan.to_download, *plan.already_current)) - if plan.is_complete - else frozenset() - ) - self._refresh_rows() - self.changed.emit() - - # -- running fetches -------------------------------------------------- - - async def _run( - self, fetch: Callable[[], Awaitable[Any]], apply: Callable[[Any], None] - ) -> None: - async def once() -> AsyncIterator[Any]: - yield await fetch() - - await self._run_stream(once, apply) - - async def _run_stream( - self, - stream: Callable[[], AsyncIterator[Any]], - apply: Callable[[Any], None], - ) -> None: - """Apply each value the stream yields; only a failure before the first - value is shown to the user, later ones are background refreshes.""" - if self._torn_down: - return - self._generation += 1 - generation = self._generation - self._busy, self._error = True, "" - self.changed.emit() - - async def retry() -> None: - await self._run_stream(stream, apply) - - self._retry = retry - shown = False - try: - async for result in stream(): - if generation != self._generation: - return - apply(result) - shown = True - self._busy = False - self.changed.emit() - except _FAILURES as exc: - if generation == self._generation: - if shown: - logger.warning( - "[workshop] background refresh failed: {}", _message(exc) - ) - else: - self._error = _message(exc) - finally: - if generation == self._generation: - self._busy = False - self.changed.emit() - if ( - self._installed_dirty - and self._tab == "Installed" - and self._detail_data is None - ): - await self.load() - - async def _run_cached( - self, - key: str, - produce: Callable[[], Awaitable[Any]], - value_type: Any, - apply: Callable[[Any], None], - *, - fresh_for: float = FRESH_SECONDS, - ) -> None: - await self._run_stream( - lambda: self._catalog.cached( - key, produce, fresh_for=fresh_for, value_type=value_type - ), - apply, - ) - - # -- loading tabs ----------------------------------------------------- - - async def load(self, *, append: bool = False) -> None: - if self._torn_down: - return - if not self._catalog.configured: - self._generation += 1 - self._busy = False - self.changed.emit() - return - self._clear_detail() - match self._tab: - case "Discover": - await self._load_discover() - case "Installed": - await self._load_installed(append) - case "Favourites": - self._generation += 1 - self._busy, self._error = False, "" - self._show_favourites() - case _: - await self._load_listing(append) - - async def _load_discover(self) -> None: - def apply(result: Discover) -> None: - self.mods_model.set_page( - self._rows(result.mods.items), result.mods.total, None - ) - self.collections_model.set_page( - self._rows(result.collections.items), result.collections.total, None - ) - - await self._run_cached("discover", self._catalog.discover, Discover, apply) - - def _show_favourites(self) -> None: - query = self._criteria.query.casefold() - items = [ - item for item in self._favourites.items if query in item.title.casefold() - ] - self.collections_model.set_page(self._rows(items), len(items), None) - self.changed.emit() - - async def _load_listing(self, append: bool) -> None: - collections = self._tab == "Collections" - model = self.collections_model if collections else self.mods_model - fetch = self._catalog.collections if collections else self._catalog.mods - query = msgspec.structs.replace( - self._criteria, cursor=model.cursor if append else None - ) - - def apply(page: CatalogPage[Any]) -> None: - model.set_page( - self._rows(page.items), page.total, page.next_cursor, append=append - ) - - if append: - await self._run(lambda: fetch(query), apply) - return - page_type = ( - CatalogPage[CatalogCollection] if collections else CatalogPage[CatalogMod] - ) - await self._run_cached( - f"{self._tab}|{query!r}", lambda: fetch(query), page_type, apply - ) - - async def _load_installed(self, append: bool) -> None: - if append: - self.mods_model.set_page( - self._rows(self._installed.next_page()), - self._installed.total, - self._installed_cursor(), - append=True, - ) - return - - self._installed_dirty = False - - def apply(mods: list[CatalogMod]) -> None: - self._installed.replace(mods) - self._recount() - self._show_installed() - - installed_ids = { - mod.published_file_id - for mod in self._ctx.all_mods.values() - if mod.published_file_id is not None - } - fingerprint = sha256(",".join(sorted(installed_ids)).encode()).hexdigest() - - await self._run_cached( - f"installed|{fingerprint}", - self._catalog.installed_with_updates, - list[CatalogMod], - apply, - ) - - def _installed_cursor(self) -> str | None: - return "more" if self._installed.has_more else None - - def _show_installed(self) -> None: - self._clear_detail() - self.mods_model.set_page( - self._rows(self._installed.visible()), - self._installed.total, - self._installed_cursor(), - ) - self.changed.emit() - - def _recount(self) -> None: - self._update_count = sum( - self._catalog.install_state(mod) == "outdated" - for mod in self._installed.mods - ) - - # -- QML slots -------------------------------------------------------- - - @asyncSlot() - async def refresh(self) -> None: - await self.load() - - @Slot(str) - def selectTab(self, tab: str) -> None: - self._go(tab.lower()) - - async def select_tab(self, tab: str) -> None: - self._tab, self._notice, self._pending = tab, "", None - self._clear_detail() - self._installed.reset_paging() - await self.load() - - @asyncSlot(str, str, str, str, str) - async def filter( - self, query: str, version: str, source: str, sort: str, tag: str - ) -> None: - self._criteria = CatalogQuery( - query=query, - tag=tag or None, - version=version or None, - sort=sort, - source=source, - ) - self._installed.search(query) - if self._tab == "Installed" and self._installed.loaded: - self._show_installed() - return - if self._tab == "Discover": - self._tab = "Mods" - await self.load() - - @asyncSlot() - async def loadMore(self) -> None: - await self.load(append=True) - - @asyncSlot() - async def retry(self) -> None: - if self._retry: - await self._retry() - - @Slot(str, str) - def openItem(self, item_id: str, kind: str) -> None: - self._go(kind, item_id) - - def _go(self, *path: str) -> None: - if self._app_ctx is None: - self.open_route(path) - else: - self._app_ctx.navigate(build_route(self.view_id, *path)) - - async def open_item(self, item_id: str, kind: str, *, record: bool = True) -> None: - self._pack_ids = frozenset() - - def apply(detail: Detail) -> None: - ref = (kind, item_id) - if self._current != ref: - if not self._detail: - self._trail.clear() - elif record and self._current is not None: - title = str(self._detail.get("title", "")) - self._trail.append((*self._current, title)) - self._current = ref - members = self._rows(detail.members) - self._detail_data = detail - self._detail = self._detail_row(detail) - self.members_model.set_page(members, len(members), None) - - await self._run_cached( - f"detail|{kind}|{item_id}", - lambda: fetch_detail(self._catalog, item_id, kind), - Detail, - apply, - ) - if kind == "collection": - await self._load_pack() - - def open_route(self, path: tuple[str, ...]) -> None: - task = asyncio.ensure_future(self.follow_route(path)) - self._route_tasks.add(task) - task.add_done_callback(self._route_tasks.discard) - - async def follow_route(self, path: tuple[str, ...]) -> None: - if self._torn_down or not path: - return - if len(path) == 1 and path[0].title() in _TABS: - await self.select_tab(path[0].title()) - elif len(path) == 2 and path[0] in ("mod", "collection"): - await self.open_item(path[1], path[0]) - else: - logger.warning( - "[workshop] unknown route {}", build_route(self.view_id, *path) - ) - - @asyncSlot() - async def back(self) -> None: - if self._torn_down: - return - if self._trail: - kind, item_id, _ = self._trail[-1] - await self.open_item(item_id, kind, record=False) - if self._current == (kind, item_id) and self._trail: - self._trail.pop() - self.changed.emit() - return - self._generation += 1 - self._clear_detail() - self._error, self._busy = "", False - self.changed.emit() - if self._tab == "Favourites": - self._show_favourites() - elif self._tab == "Installed" and self._installed_dirty: - await self.load() - - @asyncSlot(str, str) - async def downloadItem(self, item_id: str, kind: str) -> None: - await self._download( - [item_id] if kind == "mod" else [], - [item_id] if kind == "collection" else [], - ) - - @asyncSlot(str) - async def toggleActivation(self, item_id: str) -> None: - if self._torn_down: - return - installed = [ - uuid - for uuid, mod in self._ctx.all_mods.items() - if mod.published_file_id == item_id - ] - if not installed: - return - active = set(self._ctx.active_uuids) - active_copies = [uuid for uuid in installed if uuid in active] - if active_copies: - await apply_activation(self._ctx, self, disable=active_copies) - else: - await apply_activation(self._ctx, self, enable=[installed[0]]) - - @asyncSlot() - async def toggleCollection(self) -> None: - copies = self._pack_copies() - if copies is None: - return - active = set(self._ctx.active_uuids) - if all(uuid in active for uuid in copies): - await apply_activation( - self._ctx, - self, - disable=[ - uuid - for uuid in self._ctx.active_uuids - if (mod := self._ctx.all_mods.get(uuid)) is not None - and mod.published_file_id in self._pack_ids - ], - ) - else: - await apply_activation(self._ctx, self, enable=copies) - - @asyncSlot() - async def activateOnlyCollection(self) -> None: - copies = self._pack_copies() - if copies is None: - return - keep = set(copies) - mods = self._ctx.all_mods - others = [ - uuid - for uuid in self._ctx.active_uuids - if uuid not in keep - and (mod := mods.get(uuid)) is not None - and mod.published_file_id is not None - ] - await apply_activation(self._ctx, self, enable=copies, disable=others) - - @asyncSlot() - async def updateAll(self) -> None: - ids = [ - mod.id - for mod in self._installed.mods - if self._catalog.install_state(mod) == "outdated" - ] - if ids: - await self._download(ids, []) - - @asyncSlot() - async def downloadAvailable(self) -> None: - if self._torn_down: - return - plan, self._pending = self._pending, None - if plan and plan.to_download: - self._queue_plan(plan) - self.changed.emit() - - @Slot() - def dismissPlan(self) -> None: - self._pending, self._notice = None, "" - self.changed.emit() - - @Slot(str) - def openLink(self, url: str) -> None: - if safe_url(url): - QDesktopServices.openUrl(QUrl(url)) - - @Slot() - def openSettings(self) -> None: - if self._app_ctx is not None: - self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) - - @Slot(str) - def toggleFavourite(self, item_id: str) -> None: - item = self._items.get(item_id) - if isinstance(item, CatalogCollection): - self._favourites.set(item, item_id not in self._favourites) - - # -- downloads -------------------------------------------------------- - - def _queue_plan(self, plan: DownloadPlan) -> None: - self._catalog.enqueue(plan) - self._notice = queued_notice(len(plan.to_download)) - - async def _download(self, ids: list[str], collections: list[str]) -> None: - if self._torn_down: - return - self._notice, self._pending = "Preparing download…", None - self._planning += 1 - requested = {*ids, *collections} - self._preparing |= requested - self._refresh_rows() - self.changed.emit() - try: - plan = await self._catalog.plan(ids, collections) - except _FAILURES as exc: - if not self._torn_down: - self._notice = f"Could not prepare the download: {_message(exc)}" - return - finally: - self._planning -= 1 - self._preparing -= requested - if not self._torn_down: - self._refresh_rows() - self.changed.emit() - for collection in collections: - self._collection_mods[collection] = frozenset(plan.to_download) - if self._torn_down: - return - if not plan.is_complete: - self._pending, self._notice = plan, incomplete_plan_notice(plan) - elif not plan.to_download: - self._notice = "All available mods are already installed and up to date." - else: - self._queue_plan(plan) - self.changed.emit() - - # -- catalog events --------------------------------------------------- - - def _on_active_state_changed(self, _uuids: tuple[str, ...]) -> None: - self._refresh_mod_ids() - self._refresh_rows() - self.changed.emit() - - def _on_queue_changed(self, *_: object) -> None: - self._refresh_rows() - self.changed.emit() - - def _on_favourites_changed(self, _value: object) -> None: - if self._tab == "Favourites" and self._detail_data is None: - self._show_favourites() - return - self._refresh_rows() - self.changed.emit() - - @asyncSlot(str) - async def _on_url_changed(self, _url: str) -> None: - if self._torn_down: - return - self._clear_detail() - self._error, self._notice, self._pending = "", "", None - self._items.clear() - self._known_tags.clear() - for model in (self.mods_model, self.collections_model, self.members_model): - model.set_page([], 0, None) - await self.load() - - @asyncSlot(object) - async def _on_installed_changed(self, _value: None) -> None: - if self._torn_down: - return - self._installed_dirty = True - self._refresh_mod_ids() - self._recount() - self._refresh_rows() - self.changed.emit() - if self._tab == "Installed" and not self._busy and self._detail_data is None: - await self.load() - - # -- QML properties --------------------------------------------------- - - @Property(str, notify=changed) # type: ignore[arg-type] - def tab(self) -> str: - return self._tab - - @Property(str, notify=changed) # type: ignore[arg-type] - def url(self) -> str: - if self._current is not None: - return build_route(self.view_id, *self._current) - return build_route(self.view_id, self._tab.lower()) - - @Property(str, notify=changed) # type: ignore[arg-type] - def backLabel(self) -> str: - if self._trail: - return f"Back to {self._trail[-1][2]}" - return f"Back to {self._tab}" - - @Property(bool, notify=changed) # type: ignore[arg-type] - def configured(self) -> bool: - return self._catalog.configured - - @Property(bool, notify=changed) # type: ignore[arg-type] - def busy(self) -> bool: - return self._busy - - @Property(str, notify=changed) # type: ignore[arg-type] - def error(self) -> str: - return self._error - - @Property(str, notify=changed) # type: ignore[arg-type] - def notice(self) -> str: - return self._notice - - @Property(dict, notify=changed) # type: ignore[arg-type] - def detail(self) -> dict[str, Any]: - return self._detail - - @Property(bool, notify=changed) # type: ignore[arg-type] - def hasDetail(self) -> bool: - return bool(self._detail) - - @Property(bool, notify=changed) # type: ignore[arg-type] - def pendingPlan(self) -> bool: - return self._pending is not None - - @Property(bool, notify=changed) # type: ignore[arg-type] - def canDownloadAvailable(self) -> bool: - return self._pending is not None and bool(self._pending.to_download) - - @Property(str, notify=changed) # type: ignore[arg-type] - def searchQuery(self) -> str: - return self._criteria.query - - @Property(list, notify=changed) # type: ignore[arg-type] - def tagOptions(self) -> list[str]: - return ["All tags", *sorted(self._known_tags)] - - @Property(str, constant=True) # type: ignore[arg-type] - def gameVersion(self) -> str: - return self._game_version - - @Property(list, constant=True) # type: ignore[arg-type] - def versionOptions(self) -> list[str]: - current = self._criteria.version - return list( - dict.fromkeys( - [ - "All versions", - *([current] if current else []), - "1.6", - "1.5", - "1.4", - "1.3", - ] - ) - ) - - @Property(int, notify=changed) # type: ignore[arg-type] - def updateCount(self) -> int: - return self._update_count - - @Property(bool, notify=changed) # type: ignore[arg-type] - def planning(self) -> bool: - return self._planning > 0 diff --git a/src/pxmodrim/ui/settings_section.py b/src/pxmodrim/ui/settings_section.py deleted file mode 100644 index 03610e5..0000000 --- a/src/pxmodrim/ui/settings_section.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from collections.abc import Callable -from pathlib import Path -from typing import Any, ClassVar - -import msgspec -from PySide6.QtCore import Property, QObject, Slot - -from pxmodrim.core.plugin_config import PluginConfig - - -class SettingsSection(QObject): - """A plugin's group on the Settings dialog. - - ``source`` is loaded into a group titled ``title`` with this object as its - required ``section`` property. Edits stay on the object until the dialog is - saved, when ``apply()`` commits them; a cancelled dialog just drops it. - """ - - title: ClassVar[str] - source: ClassVar[Path] - - def apply(self) -> None: ... - - -type SettingsSectionFactory = Callable[[QObject], SettingsSection] - - -class PluginConfigSection(SettingsSection): - """Edits a ``PluginConfig``: QML reads ``initial`` and reports edits via ``set``.""" - - def __init__(self, config: PluginConfig[Any], parent: QObject) -> None: - super().__init__(parent) - self._config = config - self._initial: dict[str, Any] = msgspec.to_builtins(config.value) - self._draft: dict[str, Any] = {} - - @Property(dict, constant=True) # type: ignore[arg-type] - def initial(self) -> dict[str, Any]: - return self._initial - - @Slot(str, "QVariant") - def set(self, key: str, value: Any) -> None: - if key not in self._initial: - raise KeyError(key) - self._draft[key] = value - - def apply(self) -> None: - if self._draft: - self._config.update( - msgspec.convert( - {**self._initial, **self._draft}, - type(self._config.value), - strict=False, - ) - ) diff --git a/src/pxmodrim/ui/theme/__init__.py b/src/pxmodrim/ui/theme/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/ui/theme/constants.py b/src/pxmodrim/ui/theme/constants.py deleted file mode 100644 index c5dd91c..0000000 --- a/src/pxmodrim/ui/theme/constants.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QEasingCurve - -# Animation -ACCORDION_ANIMATION_DURATION_MS = 200 -ACCORDION_EASING = QEasingCurve.Type.OutCubic - -# Banner -BANNER_MAX_HEIGHT = 260 -BANNER_ASPECT_W = 16 -BANNER_ASPECT_H = 9 -BANNER_OVERLAY_HEIGHT = 120 - -# Layout -RAIL_MIN_WIDTH = 52 -RAIL_MAX_WIDTH = 180 -RAIL_COLLAPSE_WIDTH = 116 -SIDEBAR_WIDTH = 240 - -# Interaction -TOOLTIP_DELAY_MS = 400 diff --git a/src/pxmodrim/ui/theme/palette.py b/src/pxmodrim/ui/theme/palette.py deleted file mode 100644 index a486711..0000000 --- a/src/pxmodrim/ui/theme/palette.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from string import Template - -from PySide6.QtGui import QColor - - -def _tint(r: int, g: int, b: int, alpha: float) -> tuple[str, str]: - """(QSS rgba() string, QML #AARRGGBB string) for one translucent color.""" - qml = f"#{round(alpha * 255):02x}{r:02x}{g:02x}{b:02x}" - return f"rgba({r},{g},{b},{alpha})", qml - - -_PRIMARY_BG = _tint(102, 192, 244, 0.1) -_SUCCESS_BG = _tint(59, 165, 93, 0.15) -_WARNING_BG = _tint(249, 168, 37, 0.15) -_DANGER_BG = _tint(237, 66, 69, 0.15) - -# *_BG entries are rgba() for QSS only; QML rejects rgba() strings (renders opaque -# black), so QML consumers must use the *_BG_QML entries. -PALETTE: dict[str, str] = { - "ELEVATE_0": "#0b0d10", - "ELEVATE_1": "#121418", - "ELEVATE_2": "#1a1d21", - "ELEVATE_3": "#22262b", - "ELEVATE_4": "#2c3036", - "SURFACE": "#25282e", - "BORDER": "#2f333a", - "TEXT_MAIN": "#f2f3f5", - "TEXT_MUTED": "#949ba4", - "TEXT_DIM": "#6c737f", - "TEXT_ON_ACCENT": "#0b0d10", - "NEUTRAL": "#6b7280", - "PRIMARY": "#66c0f4", - "PRIMARY_HOVER": "#4aa8d8", - "PRIMARY_BG": _PRIMARY_BG[0], - "PRIMARY_BG_QML": _PRIMARY_BG[1], - "SUCCESS": "#3ba55d", - "SUCCESS_HOVER": "#2f8a4c", - "SUCCESS_BG": _SUCCESS_BG[0], - "SUCCESS_BG_QML": _SUCCESS_BG[1], - "WARNING": "#f9a825", - "WARNING_BG": _WARNING_BG[0], - "WARNING_BG_QML": _WARNING_BG[1], - "DANGER": "#ed4245", - "DANGER_HOVER": "#d1383b", - "DANGER_BG": _DANGER_BG[0], - "DANGER_BG_QML": _DANGER_BG[1], -} - -# QColor variants for QPainter delegates -PANEL_BG_Q = QColor(PALETTE["ELEVATE_2"]) -TEXT_MAIN_Q = QColor(PALETTE["TEXT_MAIN"]) -TEXT_MUTED_Q = QColor(PALETTE["TEXT_MUTED"]) - - -def get_stylesheet() -> str: - qss_path = Path(__file__).parent / "style.qss" - raw = qss_path.read_text() - return Template(raw).safe_substitute(PALETTE) diff --git a/src/pxmodrim/ui/theme/qml_theme.py b/src/pxmodrim/ui/theme/qml_theme.py deleted file mode 100644 index 0d6f705..0000000 --- a/src/pxmodrim/ui/theme/qml_theme.py +++ /dev/null @@ -1,214 +0,0 @@ -from __future__ import annotations - -import base64 -import random -from importlib.resources import files as resource_files - -from PySide6.QtCore import Property, QObject, Signal - -from pxmodrim.ui.theme import constants -from pxmodrim.ui.theme.palette import PALETTE - - -class Theme(QObject): - changed = Signal() - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self._logo_variants = self._load_logos() - self._logo_data_uri = random.choice(self._logo_variants) - - @staticmethod - def _load_logos() -> list[str]: - uris = [] - for n in range(1, 4 + 1): - raw = ( - resource_files("pxmodrim.ui.assets") / f"logo_nobg_{n}.svg" - ).read_bytes() - encoded = base64.b64encode(raw).decode("ascii") - uris.append(f"data:image/svg+xml;base64,{encoded}") - return uris - - # ── Elevation backgrounds ──────────────────────────── - - @Property(str, notify=changed) - def elevate0(self) -> str: - return PALETTE["ELEVATE_0"] - - @Property(str, notify=changed) - def elevate1(self) -> str: - return PALETTE["ELEVATE_1"] - - @Property(str, notify=changed) - def elevate2(self) -> str: - return PALETTE["ELEVATE_2"] - - @Property(str, notify=changed) - def elevate3(self) -> str: - return PALETTE["ELEVATE_3"] - - @Property(str, notify=changed) - def elevate4(self) -> str: - return PALETTE["ELEVATE_4"] - - @Property(str, notify=changed) - def border(self) -> str: - return PALETTE["BORDER"] - - # ── Text ───────────────────────────────────────────── - - @Property(str, notify=changed) - def textMain(self) -> str: - return PALETTE["TEXT_MAIN"] - - @Property(str, notify=changed) - def textMuted(self) -> str: - return PALETTE["TEXT_MUTED"] - - @Property(str, notify=changed) - def textDim(self) -> str: - return PALETTE["TEXT_DIM"] - - @Property(str, notify=changed) - def onAccent(self) -> str: - return PALETTE["TEXT_ON_ACCENT"] - - @Property(str, notify=changed) - def neutral(self) -> str: - return PALETTE["NEUTRAL"] - - # ── Accent / Semantic (*Bg are #AARRGGBB, valid QML colors) ── - - @Property(str, notify=changed) - def primary(self) -> str: - return PALETTE["PRIMARY"] - - @Property(str, notify=changed) - def primaryHover(self) -> str: - return PALETTE["PRIMARY_HOVER"] - - @Property(str, notify=changed) - def primaryBg(self) -> str: - return PALETTE["PRIMARY_BG_QML"] - - @Property(str, notify=changed) - def success(self) -> str: - return PALETTE["SUCCESS"] - - @Property(str, notify=changed) - def successHover(self) -> str: - return PALETTE["SUCCESS_HOVER"] - - @Property(str, notify=changed) - def successBg(self) -> str: - return PALETTE["SUCCESS_BG_QML"] - - @Property(str, notify=changed) - def warning(self) -> str: - return PALETTE["WARNING"] - - @Property(str, notify=changed) - def warningBg(self) -> str: - return PALETTE["WARNING_BG_QML"] - - @Property(str, notify=changed) - def danger(self) -> str: - return PALETTE["DANGER"] - - @Property(str, notify=changed) - def dangerHover(self) -> str: - return PALETTE["DANGER_HOVER"] - - @Property(str, notify=changed) - def dangerBg(self) -> str: - return PALETTE["DANGER_BG_QML"] - - # ── Design tokens ──────────────────────────────────── - - @Property(str, constant=True) - def fontFamily(self) -> str: - return "Source Sans 3, Liberation Sans, sans-serif" - - @Property(str, constant=True) - def fontMono(self) -> str: - return "DejaVu Sans Mono, Liberation Mono, monospace" - - @Property(int, constant=True) - def fontSizeXs(self) -> int: - return 10 - - @Property(int, constant=True) - def fontSizeSm(self) -> int: - return 11 - - @Property(int, constant=True) - def fontSizeMd(self) -> int: - return 13 - - @Property(int, constant=True) - def fontSizeLg(self) -> int: - return 14 - - @Property(int, constant=True) - def fontSizeXl(self) -> int: - return 18 - - @Property(int, constant=True) - def radiusXs(self) -> int: - return 3 - - @Property(int, constant=True) - def radiusSm(self) -> int: - return 4 - - @Property(int, constant=True) - def radiusMd(self) -> int: - return 6 - - @Property(int, constant=True) - def radiusLg(self) -> int: - return 8 - - @Property(int, constant=True) - def radiusPill(self) -> int: - return 999 - - @Property(int, constant=True) - def scrollbarWidth(self) -> int: - return 6 - - @Property(int, constant=True) - def tooltipDelay(self) -> int: - return constants.TOOLTIP_DELAY_MS - - @Property(float, constant=True) - def disabledOpacity(self) -> float: - return 0.45 - - @Property(str, constant=True) - def overlay(self) -> str: - return "#8c000000" - - @Property(int, constant=True) - def railMinWidth(self) -> int: - return constants.RAIL_MIN_WIDTH - - @Property(int, constant=True) - def railMaxWidth(self) -> int: - return constants.RAIL_MAX_WIDTH - - @Property(int, constant=True) - def railCollapseWidth(self) -> int: - return constants.RAIL_COLLAPSE_WIDTH - - @Property(int, constant=True) - def sidebarWidth(self) -> int: - return constants.SIDEBAR_WIDTH - - @Property(list, constant=True) - def logoVariants(self) -> list[str]: - return self._logo_variants - - @Property(str, constant=True) - def logoFileDataUri(self) -> str: - return self._logo_data_uri diff --git a/src/pxmodrim/ui/ui_prefs.py b/src/pxmodrim/ui/ui_prefs.py deleted file mode 100644 index 0eefe23..0000000 --- a/src/pxmodrim/ui/ui_prefs.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.constants import LaunchStrategy - - -class UIPrefs(msgspec.Struct): - desc_expanded: bool = False - launch_strategy: LaunchStrategy = LaunchStrategy.DIRECT - skipped_update_tag: str = "" - rail_collapsed: bool = False diff --git a/src/pxmodrim/ui/views/__init__.py b/src/pxmodrim/ui/views/__init__.py deleted file mode 100644 index fc7e15f..0000000 --- a/src/pxmodrim/ui/views/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""UI view panels for PxModRim.""" - -from __future__ import annotations - -from pxmodrim.ui.views.base import BaseViewPanel -from pxmodrim.ui.views.mods_view import ModsViewPanel - -__all__ = [ - "BaseViewPanel", - "ModsViewPanel", -] diff --git a/src/pxmodrim/ui/views/base.py b/src/pxmodrim/ui/views/base.py deleted file mode 100644 index 0bf0c0b..0000000 --- a/src/pxmodrim/ui/views/base.py +++ /dev/null @@ -1,49 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtGui import QColor -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QVBoxLayout, QWidget - -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.context import RailView -from pxmodrim.ui.theme.palette import PALETTE -from pxmodrim.ui.ui_prefs import UIPrefs - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext - - -class BaseViewPanel(RailView): - """ - UI-side whole-window view contributed to the left icon rail. - - Core never imports this; views are discovered entirely within ``ui/``. - Subclasses must define ``view_id``, ``icon_name`` and ``label``. - """ - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - super().__init__(ctx, qml_engine, parent, app_ctx) - self._ctx = ctx - self._app_ctx = app_ctx - self._qml_engine = qml_engine - self._ui_prefs = app_ctx.ui_prefs if app_ctx is not None else UIPrefs() - self.setAutoFillBackground(True) - self._root = QVBoxLayout(self) - self._root.setContentsMargins(0, 0, 0, 0) - self._root.setSpacing(0) - self.set_clear_color() - - def set_clear_color(self, color: str = PALETTE["ELEVATE_0"]) -> None: - from PySide6.QtGui import QPalette - - palette = self.palette() - palette.setColor(QPalette.ColorRole.Window, QColor(color)) - self.setPalette(palette) diff --git a/src/pxmodrim/ui/views/mods_view.py b/src/pxmodrim/ui/views/mods_view.py deleted file mode 100644 index fcc94f5..0000000 --- a/src/pxmodrim/ui/views/mods_view.py +++ /dev/null @@ -1,82 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from PySide6.QtCore import Qt, Signal - -if TYPE_CHECKING: - from pxmodrim.ui.context import AppContext -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QHBoxLayout, QWidget - -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.panels.mod_info_panel import ModInfoPanel -from pxmodrim.ui.panels.mod_list_panel import ModListPanel -from pxmodrim.ui.panels.sidebar_panel import SidebarPanel -from pxmodrim.ui.theme.constants import SIDEBAR_WIDTH -from pxmodrim.ui.views.base import BaseViewPanel - - -class ModsViewPanel(BaseViewPanel): - view_id = "mods" - icon_name = "mods" - label = "Mods" - - entry_selected = Signal(object) - mod_selected = Signal(str) - active_mods_changed = Signal() - order_changed = Signal() - - def __init__( - self, - ctx: CoreContext, - qml_engine: QQmlEngine | None = None, - parent: QWidget | None = None, - app_ctx: AppContext | None = None, - ) -> None: - """Initialize mods view with sidebar, mod list, and mod info panels.""" - super().__init__(ctx, qml_engine, parent, app_ctx=app_ctx) - - content = QWidget(self) - content.setObjectName("contentArea") - h_layout = QHBoxLayout(content) - h_layout.setContentsMargins(0, 0, 0, 0) - h_layout.setSpacing(0) - - self.sidebar = SidebarPanel(self._ctx, self._qml_engine, content) - self.sidebar.setObjectName("sidebarPanel") - self.sidebar.setFixedWidth(SIDEBAR_WIDTH) - self.sidebar.entry_selected.connect(self.entry_selected) - h_layout.addWidget(self.sidebar) - - self.mod_list = ModListPanel(self._ctx, self._qml_engine, content) - self.mod_list.setObjectName("modListPanel") - self.mod_list.mod_selected.connect(self.mod_selected) - self.mod_list.active_mods_changed.connect( - self.active_mods_changed, Qt.ConnectionType.QueuedConnection - ) - self.mod_list.order_changed.connect( - self.order_changed, Qt.ConnectionType.QueuedConnection - ) - h_layout.addWidget(self.mod_list, stretch=3) - - self.mod_info = ModInfoPanel( - self._ctx, self._qml_engine, ui_prefs=self._ui_prefs, parent=content - ) - self.mod_info.setObjectName("modInfoPanel") - self.mod_info.setMinimumWidth(300) - h_layout.addWidget(self.mod_info, stretch=2) - - self._root.addWidget(content, stretch=1) - - def apply_sidebar_entry(self, entry: object) -> None: - self.mod_list.clearSelection() - from pxmodrim.core.models.view.sidebar import SidebarEntry - - if isinstance(entry, SidebarEntry): - self.mod_list.set_sidebar_filter(entry.visible_uuids) - - def apply_current_sidebar_filter(self) -> None: - entry = self.sidebar.current_entry() - if entry is not None: - self.mod_list.set_sidebar_filter(entry.visible_uuids) diff --git a/src/pxmodrim/ui/window/__init__.py b/src/pxmodrim/ui/window/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/pxmodrim/ui/window/actions.py b/src/pxmodrim/ui/window/actions.py deleted file mode 100644 index 8350438..0000000 --- a/src/pxmodrim/ui/window/actions.py +++ /dev/null @@ -1,104 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum - -from PySide6.QtGui import QAction, QKeySequence -from PySide6.QtWidgets import QWidget - - -class ActionId(StrEnum): - SAVE = "save" - RESTORE = "restore" - SETTINGS = "settings" - QUIT = "quit" - REFRESH = "refresh" - FULL_RESCAN = "full_rescan" - AUTO_SORT = "auto_sort" - UPDATE_MODS = "update_mods" - FOCUS_SEARCH = "focus_search" - NEXT_VIEW = "next_view" - PREV_VIEW = "prev_view" - FULLSCREEN = "fullscreen" - REPORT_ISSUE = "report_issue" - UPLOAD_LOGS = "upload_logs" - OPEN_LOGS = "open_logs" - SHORTCUTS = "shortcuts" - CHECK_UPDATES = "check_updates" - ABOUT = "about" - - -@dataclass(frozen=True, slots=True) -class ActionSpec: - id: ActionId - menu_text: str - label: str - shortcut: str = "" - - def tooltip(self) -> str: - if not self.shortcut: - return self.label - return f"{self.label} ({_native(self.shortcut)})" - - -ACTION_SPECS: tuple[ActionSpec, ...] = ( - ActionSpec(ActionId.SAVE, "&Save Mod List", "Save mod list", "Ctrl+S"), - ActionSpec(ActionId.RESTORE, "&Restore Mod List\u2026", "Restore mod list"), - ActionSpec(ActionId.SETTINGS, "&Settings\u2026", "Settings", "Ctrl+,"), - ActionSpec(ActionId.QUIT, "&Quit", "Quit", "Ctrl+Q"), - ActionSpec(ActionId.REFRESH, "&Refresh Mods", "Refresh mods", "F5"), - ActionSpec( - ActionId.FULL_RESCAN, "&Full Mod Rescan", "Full mod rescan", "Ctrl+Shift+R" - ), - ActionSpec(ActionId.AUTO_SORT, "&Auto-Sort Mods", "Auto-sort mods"), - ActionSpec(ActionId.UPDATE_MODS, "&Update Mods", "Update downloaded mods"), - ActionSpec(ActionId.FOCUS_SEARCH, "Focus &Search", "Focus search", "Ctrl+F"), - ActionSpec(ActionId.NEXT_VIEW, "&Next View", "Next view", "Ctrl+Tab"), - ActionSpec(ActionId.PREV_VIEW, "&Previous View", "Previous view", "Ctrl+Shift+Tab"), - ActionSpec(ActionId.FULLSCREEN, "Toggle &Fullscreen", "Toggle fullscreen", "F11"), - ActionSpec(ActionId.REPORT_ISSUE, "&Report Issue", "Report issue"), - ActionSpec( - ActionId.UPLOAD_LOGS, - "Log && System &Info\u2026", - "Log & system info", - ), - ActionSpec(ActionId.OPEN_LOGS, "Open &Logs Folder", "Open logs folder"), - ActionSpec(ActionId.SHORTCUTS, "&Keyboard Shortcuts", "Keyboard shortcuts"), - ActionSpec(ActionId.CHECK_UPDATES, "Check for &Updates\u2026", "Check for updates"), - ActionSpec(ActionId.ABOUT, "&About PxModRim", "About PxModRim", "F1"), -) - -ACTIONS: dict[ActionId, ActionSpec] = {spec.id: spec for spec in ACTION_SPECS} - -VIEW_SWITCH_KEYS: tuple[str, ...] = tuple(f"Ctrl+{i}" for i in range(1, 10)) - -_WINDOW_ROWS: tuple[tuple[str, str], ...] = ( - ("Ctrl+1\u20139", "Switch to view"), - ("Alt", "Show or hide menu bar"), - ("Esc", "Hide menu bar"), -) - - -def _native(sequence: str) -> str: - return QKeySequence(sequence).toString(QKeySequence.SequenceFormat.NativeText) - - -def create_actions(window: QWidget) -> dict[ActionId, QAction]: - """Build every window action and register it on *window*. - - Adding the actions to the window (not just to the hidden menu bar) keeps - their shortcuts live while the menu bar is hidden. - """ - actions: dict[ActionId, QAction] = {} - for spec in ACTION_SPECS: - action = QAction(spec.menu_text, window) - if spec.shortcut: - action.setShortcut(QKeySequence(spec.shortcut)) - window.addAction(action) - actions[spec.id] = action - return actions - - -def shortcut_rows() -> tuple[tuple[str, str], ...]: - rows = tuple((_native(s.shortcut), s.label) for s in ACTION_SPECS if s.shortcut) - return rows + _WINDOW_ROWS diff --git a/src/pxmodrim/ui/window/main_window.py b/src/pxmodrim/ui/window/main_window.py deleted file mode 100644 index b1fd429..0000000 --- a/src/pxmodrim/ui/window/main_window.py +++ /dev/null @@ -1,943 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from importlib.resources import files as resource_files -from typing import TYPE_CHECKING - -import msgspec -from loguru import logger -from PySide6.QtCore import QEvent, QObject, Qt, QUrl -from PySide6.QtGui import ( - QAction, - QCloseEvent, - QDesktopServices, - QIcon, - QKeyEvent, - QKeySequence, - QResizeEvent, -) -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import ( - QApplication, - QCheckBox, - QDialog, - QLineEdit, - QMainWindow, - QMessageBox, - QSplitter, - QStackedWidget, - QVBoxLayout, - QWidget, -) -from qasync import asyncSlot - -from pxmodrim.core.config import config_dir -from pxmodrim.core.constants import AfterLaunch, LaunchStrategy -from pxmodrim.core.downloads import DownloadProgress, DownloadResult, download_manager -from pxmodrim.core.models.view.sidebar import SidebarEntry -from pxmodrim.core.services.update_service import UpdateCheckError, UpdateService -from pxmodrim.core.support import get_app_version -from pxmodrim.ui.components import ( - HeaderController, - HeaderPanel, - ToastManager, - ViewRailPanel, - create_qml_engine, -) -from pxmodrim.ui.components.dialogs import await_dialog -from pxmodrim.ui.components.mod_updates import ( - confirm_update, -) -from pxmodrim.ui.config import save_ui_prefs -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.mod_selection import ModSelectionPresenter -from pxmodrim.ui.navigation import SETTINGS_VIEW_ID, build_route, parse_route -from pxmodrim.ui.panels.about_panel import AboutPanel -from pxmodrim.ui.panels.keyboard_shortcuts_dialog import ( - QML_SHORTCUTS, - KeyboardShortcutsDialog, -) -from pxmodrim.ui.panels.restore_snapshot_dialog import ( - ConfirmRestoreDialog, - RestoreSnapshotDialog, -) -from pxmodrim.ui.panels.settings_panel import SettingsPanel -from pxmodrim.ui.panels.update_dialog import SKIP_RESULT, UpdateDialog -from pxmodrim.ui.panels.upload_report_dialog import handle_upload_report -from pxmodrim.ui.theme.constants import ( - RAIL_COLLAPSE_WIDTH, - RAIL_MAX_WIDTH, - RAIL_MIN_WIDTH, -) -from pxmodrim.ui.theme.qml_theme import Theme -from pxmodrim.ui.window.actions import ( - ACTIONS, - VIEW_SWITCH_KEYS, - ActionId, - create_actions, - shortcut_rows, -) -from pxmodrim.ui.window.menu_bar import MenuBar - -_ISSUES_URL = "https://github.com/PyXiion/PxModRim/issues" -_HELP_ACTIONS = ( - ActionId.REPORT_ISSUE, - ActionId.UPLOAD_LOGS, - ActionId.OPEN_LOGS, - ActionId.SHORTCUTS, - ActionId.CHECK_UPDATES, - ActionId.ABOUT, -) -_WINDOW_TITLE = "PxModRim[*]" - - -if TYPE_CHECKING: - from pxmodrim.core.models.view.diagnostics import ModDiagnosticsView - - -class UnsavedChangesDialog(QMessageBox): - def __init__(self, parent: QWidget) -> None: - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Question) - self.setWindowTitle("Unsaved Changes") - self.setText("The active mod list has unsaved changes.") - self.setInformativeText( - "Save before closing, discard the changes, or cancel closing." - ) - self.setStandardButtons( - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel - ) - self.setDefaultButton(QMessageBox.StandardButton.Save) - - -class LaunchConfirmDialog(QMessageBox): - def __init__(self, parent: QWidget, title: str, text: str, info: str, accept: str): - super().__init__(parent) - self.setIcon(QMessageBox.Icon.Warning) - self.setWindowTitle(title) - self.setText(text) - self.setInformativeText(info) - self.setStandardButtons( - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel - ) - self.button(QMessageBox.StandardButton.Ok).setText(accept) - self.setDefaultButton(QMessageBox.StandardButton.Cancel) - self.setCheckBox(QCheckBox("Don't ask again", self)) - - def dont_ask_again(self) -> bool: - box = self.checkBox() - return box is not None and box.isChecked() - - -class MainWindow(QMainWindow): - def __init__(self, app_ctx: AppContext) -> None: - """Initialize the main application window.""" - super().__init__() - - self._app_quit_callback: Callable[[], None] | None = None - self._is_frameless: bool = False - self._app_ctx = app_ctx - self._ctx = app_ctx.core - self._ui_prefs = app_ctx.ui_prefs - self._selected_uuid: str | None = None - self._mods_view = None - self._selection: ModSelectionPresenter | None = None - self._saved_active_uuids = self._ctx.loaded_active_uuids - self._unsaved_changes = False - self._close_prompt_open = False - self._close_confirmed = False - self._close_task: asyncio.Task[None] | None = None - self._update_service = UpdateService(get_app_version()) - self._update_task: asyncio.Task[None] | None = None - self._settings_task: asyncio.Task[None] | None = None - self._launch_task: asyncio.Task[None] | None = None - - self._setup_window_basics() - self._setup_qml() - self._setup_header_and_shortcuts() - self._ctx.active_state_changed.connect(self._on_active_state_changed) - self._ctx.mod_service.mods_changed.connect(self._on_mods_reloaded) - self._setup_content_and_views() - self._setup_toast_and_events() - - def _setup_window_basics(self) -> None: - logger.debug("main_window: setting up window basics") - self.setWindowTitle(_WINDOW_TITLE) - self.setWindowIcon( - QIcon(str(resource_files("pxmodrim.ui.assets") / "logo.svg")) - ) - self.resize(1400, 850) - - self._ctx.diagnostics_service.diagnostics_summary_changed.connect( - self._on_diagnostics_summary_changed - ) - - self._is_frameless = self._try_frameless() - self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) - - def _setup_qml(self) -> None: - logger.debug("main_window: setting up QML engine and theme") - self._qml_engine = create_qml_engine(self) - self._theme = Theme(self) - self._qml_engine.rootContext().setContextProperty("Theme", self._theme) - - @property - def qml_engine(self) -> QQmlEngine: - return self._qml_engine - - def _setup_header_and_shortcuts(self) -> None: - logger.debug("main_window: setting up header and shortcuts") - self._downloads = download_manager(self._ctx) - self._downloads_refresh: asyncio.Task[int] | None = None - self._header_controller = HeaderController( - is_frameless=self._is_frameless, - initial_strategy=int(self._ui_prefs.launch_strategy), - app_version=get_app_version(), - tooltips={ - "refresh": ACTIONS[ActionId.REFRESH].tooltip(), - "sort": ACTIONS[ActionId.AUTO_SORT].tooltip(), - "save": ACTIONS[ActionId.SAVE].tooltip(), - "update_mods": ACTIONS[ActionId.UPDATE_MODS].tooltip(), - }, - downloads_available=self._downloads.available, - ) - self._header_controller.refresh_requested.connect(self._refresh_mods) - self._header_controller.sort_requested.connect(self._auto_sort) - self._header_controller.save_requested.connect(self._save_mods_config) - self._header_controller.launch_requested.connect(self._launch_game) - self._header_controller.strategy_changed.connect(self._on_strategy_changed) - self._header_controller.minimize_requested.connect(self.showMinimized) - self._header_controller.maximize_requested.connect(self._toggle_maximized) - self._header_controller.close_requested.connect(self.close) - self._header_controller.drag_started.connect(self._start_system_move) - self._header_controller.downloads_requested.connect(self._open_downloads) - self._app_ctx.set_navigator(self._open_route) - self._header_controller.update_mods_requested.connect(self._update_mods) - - self._header = HeaderPanel(self._header_controller, self._qml_engine) - - self._actions = create_actions(self) - handlers = { - ActionId.SAVE: self._save_mods_config, - ActionId.RESTORE: self._restore_snapshot, - ActionId.SETTINGS: lambda: self._app_ctx.navigate( - build_route(SETTINGS_VIEW_ID) - ), - ActionId.QUIT: self.close, - ActionId.REFRESH: self._refresh_mods, - ActionId.FULL_RESCAN: self._full_rescan, - ActionId.UPDATE_MODS: self._update_mods, - ActionId.AUTO_SORT: self._auto_sort, - ActionId.FOCUS_SEARCH: self._focus_search, - ActionId.NEXT_VIEW: lambda: self._cycle_view(1), - ActionId.PREV_VIEW: lambda: self._cycle_view(-1), - ActionId.FULLSCREEN: self._toggle_fullscreen, - ActionId.REPORT_ISSUE: self._open_issue_tracker, - ActionId.UPLOAD_LOGS: self._upload_log_and_system_info, - ActionId.OPEN_LOGS: self._open_logs_folder, - ActionId.SHORTCUTS: self._show_shortcuts, - ActionId.CHECK_UPDATES: self._check_updates_manually, - ActionId.ABOUT: self._show_about, - } - for action_id, handler in handlers.items(): - self._actions[action_id].triggered.connect(handler) - self._actions[ActionId.UPDATE_MODS].setEnabled(self._downloads.available) - - for index, key in enumerate(VIEW_SWITCH_KEYS): - switch = QAction(self) - switch.setShortcut(QKeySequence(key)) - switch.triggered.connect(lambda _=False, i=index: self._select_view(i)) - self.addAction(switch) - - self._menu_bar = MenuBar(self._actions, self) - - def _focus_search(self) -> None: - search = getattr(self._stack.currentWidget(), "search_input", None) - if isinstance(search, QLineEdit): - search.setFocus() - elif self._mods_view is not None: - self._mods_view.mod_list.search_input.setFocus() - - def _toggle_fullscreen(self) -> None: - if self.isFullScreen(): - self.showNormal() - else: - self.showFullScreen() - - def _open_route(self, url: str) -> None: - route = parse_route(url) - if route is None: - logger.warning("main_window: ignoring non-route link {}", url) - return - logger.debug("main_window: navigating to {}", url) - if route.view_id == SETTINGS_VIEW_ID: - self._start_settings() - return - for index, view in enumerate(self._views): - if view.view_id == route.view_id: - self._show_view(index) - view.open_route(route.path) - return - logger.warning("main_window: no view handles {}", url) - - def _open_downloads(self) -> None: - view = next((v for v in self._views if v.lists_downloads), None) - if view is not None: - self._app_ctx.navigate(build_route(view.view_id)) - - def _select_view(self, index: int) -> None: - if 0 <= index < self._stack.count(): - self._app_ctx.navigate(build_route(self._views[index].view_id)) - - def _show_view(self, index: int) -> None: - self._rail.set_current(index) - self._on_rail_tab_changed(index) - - def _cycle_view(self, direction: int) -> None: - count = self._stack.count() - if count: - self._select_view((self._stack.currentIndex() + direction) % count) - - @asyncSlot() - async def _full_rescan(self) -> None: - await self._reload_mods(full=True) - - @asyncSlot() - async def _show_shortcuts(self) -> None: - await await_dialog( - KeyboardShortcutsDialog, shortcut_rows() + QML_SHORTCUTS, self - ) - - @staticmethod - def _open_issue_tracker() -> None: - QDesktopServices.openUrl(QUrl(_ISSUES_URL)) - - @staticmethod - def _open_logs_folder() -> None: - QDesktopServices.openUrl(QUrl.fromLocalFile(str(config_dir() / "logs"))) - - def _on_downloads_progress(self, progress: DownloadProgress) -> None: - self._header_controller.set_downloads_progress( - f"Updating mods: {progress.completed} / {progress.total}" - f" · {progress.bytes_done / 2**20:.0f} MB", - progress.completed, - progress.total, - ) - - def _setup_content_and_views(self) -> None: - logger.debug("main_window: setting up content and views") - rail_views = self._app_ctx.rail_views - - outer = QWidget() - outer.setObjectName("outerContainer") - outer_layout = QVBoxLayout(outer) - outer_layout.setContentsMargins(0, 0, 0, 0) - outer_layout.setSpacing(0) - outer_layout.addWidget(self._header) - outer_layout.addWidget(self._menu_bar) - self._menu_bar.hide() - - rail_tabs = [ - {"viewId": v.view_id, "icon": v.icon_name, "label": v.label} - for v in rail_views - ] - self._rail = ViewRailPanel( - rail_tabs, - [{"id": str(aid), "label": ACTIONS[aid].label} for aid in _HELP_ACTIONS], - self._qml_engine, - ) - self._rail.setObjectName("viewRail") - self._rail.setMinimumWidth(RAIL_MIN_WIDTH) - self._rail.setMaximumWidth(RAIL_MAX_WIDTH) - self._stack = QStackedWidget() - self._stack.setObjectName("viewStack") - - self._splitter = QSplitter(Qt.Orientation.Horizontal) - self._splitter.setContentsMargins(0, 0, 0, 0) - self._splitter.setHandleWidth(1) - self._splitter.addWidget(self._rail) - self._splitter.addWidget(self._stack) - self._splitter.setStretchFactor(0, 0) - self._splitter.setStretchFactor(1, 1) - self._splitter.setCollapsible(0, False) - self._splitter.setCollapsible(1, False) - rail_width = RAIL_MIN_WIDTH if self._ui_prefs.rail_collapsed else RAIL_MAX_WIDTH - self._splitter.setSizes([rail_width, self.width() - rail_width]) - self._rail.currentChanged.connect(self._select_view) - self._rail.hovered.connect(self._on_rail_hovered) - self._rail.settings_requested.connect( - lambda: self._app_ctx.navigate(build_route(SETTINGS_VIEW_ID)) - ) - self._rail.help_action_requested.connect( - lambda action_id: self._actions[ActionId(action_id)].trigger() - ) - self._splitter.splitterMoved.connect(self._snap_rail) - - self._views: list = [] - for view_cls in rail_views: - view = view_cls( - self._ctx, self._qml_engine, parent=self._stack, app_ctx=self._app_ctx - ) - self._views.append(view) - self._stack.addWidget(view) - - self._mods_view = next((v for v in self._views if v.view_id == "mods"), None) - if self._mods_view is not None: - self.sidebar = self._mods_view.sidebar - self.mod_list = self._mods_view.mod_list - self.mod_info = self._mods_view.mod_info - self._mods_view.entry_selected.connect(self._on_entry_selected) - self._mods_view.mod_selected.connect(self._on_mod_selected) - self.mod_info.mod_requested.connect(self._on_mod_requested) - self._selection = ModSelectionPresenter(self._ctx, self.mod_info) - - outer_layout.addWidget(self._splitter, stretch=1) - self.setCentralWidget(outer) - - def _setup_toast_and_events(self) -> None: - logger.debug("main_window: setting up toast manager and events") - self._toast_manager = ToastManager(self.centralWidget()) - self._toast_manager.resize_to_parent() - self._app_ctx.toasts = self._toast_manager - - self._ctx.diagnostics_service.status_message_changed.connect( - self._on_status_message - ) - self._ctx.diagnostics_service.background_task_changed.connect( - self._header_controller.set_task_text - ) - if self._downloads.available: - self._downloads.busy_changed.connect(self._on_downloads_busy) - self._downloads.download_progress.connect(self._on_downloads_progress) - self._downloads.download_finished.connect(self._on_downloads_finished) - self._downloads.status_message_changed.connect(self._on_status_message) - - self.installEventFilter(self) - - def _try_frameless(self) -> bool: - try: - self.setWindowFlags( - Qt.WindowType.FramelessWindowHint - | Qt.WindowType.Window - | Qt.WindowType.WindowMinimizeButtonHint - | Qt.WindowType.WindowMaximizeButtonHint - | Qt.WindowType.WindowCloseButtonHint - ) - if self.windowFlags() & Qt.WindowType.FramelessWindowHint: - logger.debug("Frameless window enabled") - return True - logger.info("Frameless not supported, using native frame") - return False - except (OSError, AttributeError) as exc: - logger.warning("Failed to set frameless window: {}", exc) - return False - - def _toggle_maximized(self) -> None: - if self.isMaximized(): - self.showNormal() - else: - self.showMaximized() - self._header_controller.set_maximized(self.isMaximized()) - - def _start_system_move(self) -> None: - if not self._is_frameless: - return - win = self.windowHandle() - if win is not None: - win.startSystemMove() - - def set_app_quit_callback(self, callback: Callable[[], None]) -> None: - """Store the callback used to end the async run loop on close.""" - self._app_quit_callback = callback - - async def _confirm_close(self) -> None: - try: - result, _ = await await_dialog(UnsavedChangesDialog, self) - finally: - self._close_prompt_open = False - - if result == QMessageBox.StandardButton.Save: - if not await self._save_active_mods(): - return - elif result != QMessageBox.StandardButton.Discard: - return - - self._close_confirmed = True - self.close() - - def closeEvent(self, event: QCloseEvent) -> None: - if self._unsaved_changes and not self._close_confirmed: - event.ignore() - if not self._close_prompt_open: - self._close_prompt_open = True - self._close_task = asyncio.create_task(self._confirm_close()) - return - logger.info("main_window: shutting down") - if self._update_task is not None: - self._update_task.cancel() - if self._launch_task is not None: - self._launch_task.cancel() - # Release WebEngine views (their dedicated profiles) before the - # Qt widget tree is torn down. Then disconnect aboutToQuit: on this - # Chromium/Qt build, WebEngine registers an aboutToQuit handler - # that dereferences a freed object and SIGSEGVs during shutdown - # once the Steam tab has been opened. We drop every aboutToQuit - # connection (removing Chromium's) and instead fire our own quit - # callback directly, so the async run loop ends cleanly and the - # process exits without the crashing dispatch. - for view in self._views: - teardown = getattr(view, "teardown", None) - if callable(teardown): - teardown() - - app = QApplication.instance() - if app is not None: - app.aboutToQuit.disconnect() - if self._app_quit_callback is not None: - self._app_quit_callback() - event.accept() - self.deleteLater() - - # ── Event filter (Alt → toggle menu bar) ───────────── - - def eventFilter(self, obj: QObject, event: QEvent) -> bool: - if isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress: - if ( - event.key() == Qt.Key.Key_Alt - and isinstance(obj, QWidget) - and obj.window() is self - ): - self._toggle_menu_bar() - return True - if ( - event.key() == Qt.Key.Key_Escape - and self._menu_bar.isVisible() - and isinstance(obj, QWidget) - and obj.window() is self - ): - self._menu_bar.hide() - return True - return super().eventFilter(obj, event) - - def _toggle_menu_bar(self) -> None: - shown = not self._menu_bar.isVisible() - self._menu_bar.setVisible(shown) - if shown: - self._menu_bar.setFocus() - - # ── Public ────────────────────────────────────────────── - - @asyncSlot() - async def _refresh_mods(self) -> None: - await self._reload_mods(full=False) - - async def _reload_mods(self, *, full: bool) -> None: - self._toast_manager.info( - "Rescanning all mods\u2026" if full else "Refreshing mods\u2026" - ) - count = await self._app_ctx.refresh_mods(full=full) - if not count: - self._toast_manager.warning("No mods found") - return - self._toast_manager.success( - f"Rescanned {count} mods" if full else f"Reloaded {count} mods" - ) - - # ── Private slots ─────────────────────────────────────── - - def _on_status_message(self, message: str) -> None: - self._toast_manager.info(message) - - @asyncSlot() - async def _update_mods(self) -> None: - downloads = self._downloads - if not downloads.available: - return - if downloads.is_downloading: - logger.info("[downloads] stop requested from the header") - downloads.cancel() - return - ids = downloads.updatable_ids(self._ctx.all_mods.values()) - if not ids: - self._toast_manager.info("No downloaded mods to update") - return - if not await confirm_update(len(ids), self): - return - logger.info("[downloads] update-all requested for {} mods", len(ids)) - self._toast_manager.info(f"Updating {len(ids)} mods\u2026") - try: - await downloads.download_mods(ids) - except (RuntimeError, ValueError) as exc: - self._toast_manager.error(str(exc)) - - def _on_downloads_busy(self, busy: bool) -> None: - self._header_controller.set_downloads_busy(busy) - self._actions[ActionId.UPDATE_MODS].setEnabled(not busy) - if busy: - self._header_controller.set_downloads_progress("Updating mods…", 0, 0) - - def _on_downloads_finished(self, result: DownloadResult) -> None: - ok, failed = len(result.succeeded), len(result.failed) - changed = len(result.changed) - if not ok and not failed: - self._toast_manager.info("Download cancelled") - return - summary = f"{changed} updated, {ok - changed} unchanged" - if failed: - by_id = download_manager(self._ctx).names_by_id(self._ctx.all_mods.values()) - shown = ", ".join(by_id.get(pid, pid) for pid in result.failed[:3]) - more = "…" if failed > 3 else "" - self._toast_manager.warning(f"{summary}, {failed} failed: {shown}{more}") - else: - self._toast_manager.success(summary) - if changed: - self._downloads_refresh = asyncio.ensure_future( - self._app_ctx.refresh_mods() - ) - - def _start_settings(self) -> None: - if self._settings_task is not None and not self._settings_task.done(): - return - self._settings_task = asyncio.create_task(self._open_settings()) - - async def _open_settings(self) -> None: - result, dialog = await await_dialog( - SettingsPanel, - self._ctx, - self._qml_engine, - self, - self._app_ctx.settings_sections, - ) - if result != QDialog.DialogCode.Accepted: - return - cfg = dialog.get_config() - if not cfg.paths.game: - logger.warning("Settings saved without a game path") - self._ctx.config_service.save("config.json", cfg) - self._ctx.update_config(cfg) - self._ctx.reset_providers(cfg.paths) - await self._ctx.mod_service.reload() - self._toast_manager.success("Settings saved") - - @asyncSlot() - async def _show_about(self) -> None: - await await_dialog( - AboutPanel, self, ctx=self._ctx, toast_manager=self._toast_manager - ) - - def start_startup_update_check(self) -> None: - self._start_update_check(manual=False) - - def _check_updates_manually(self) -> None: - self._start_update_check(manual=True) - - def _start_update_check(self, *, manual: bool) -> None: - if self._update_task is not None and not self._update_task.done(): - return - self._update_task = asyncio.create_task(self._check_for_updates(manual=manual)) - - async def _check_for_updates(self, *, manual: bool) -> None: - try: - release = await self._update_service.check() - except UpdateCheckError as exc: - logger.warning("Update check failed: {}", exc) - if manual: - self._toast_manager.error("Could not check for updates") - return - if release is None: - if manual: - self._toast_manager.success("PxModRim is up to date") - return - if not manual and release.tag == self._ui_prefs.skipped_update_tag: - logger.debug("Update {} was skipped by the user", release.tag) - return - - result, _ = await await_dialog( - UpdateDialog, release, self._update_service.current_version, self - ) - if result == QDialog.DialogCode.Accepted: - QDesktopServices.openUrl(QUrl(release.url)) - elif result == SKIP_RESULT: - self._ui_prefs.skipped_update_tag = release.tag - save_ui_prefs(self._ui_prefs, self._ctx.config_service) - - @asyncSlot() - async def _upload_log_and_system_info(self) -> None: - await handle_upload_report(self, self._ctx, self._toast_manager) - - @asyncSlot() - async def _restore_snapshot(self) -> None: - snapshots = self._ctx.mod_service.get_snapshots() - if not snapshots: - self._toast_manager.warning("No saved mod lists are available") - return - - result, dialog = await await_dialog(RestoreSnapshotDialog, snapshots, self) - if result != QDialog.DialogCode.Accepted: - return - - chosen = dialog.selected_snapshot - if chosen is None: - return - confirm_result, _ = await await_dialog(ConfirmRestoreDialog, chosen.name, self) - if confirm_result != QMessageBox.StandardButton.Yes: - return - - if await self._ctx.mod_service.restore_snapshot(chosen): - self._toast_manager.success(f"Restored mod list from {chosen.name}") - else: - self._toast_manager.error("The saved mod list is invalid or cannot be read") - - @asyncSlot() - async def _auto_sort(self) -> None: - count, elapsed = await self._ctx.auto_sort() - self._toast_manager.success(f"Sorted {count} mods in {elapsed:.0f}ms") - - def _on_active_state_changed(self, _active_uuids: tuple[str, ...]) -> None: - self._refresh_unsaved_state() - - def _on_mods_reloaded(self, _: None = None) -> None: - # mods_changed fires after the refresh finishes, so edits made since - # ctx.load() are already in ctx.active_uuids; the baseline must be the - # list that was read from disk. - self._saved_active_uuids = self._ctx.loaded_active_uuids - self._refresh_unsaved_state() - - def _refresh_unsaved_state(self) -> None: - self._unsaved_changes = self._ctx.active_uuids != self._saved_active_uuids - self._header_controller.set_unsaved_changes(self._unsaved_changes) - self.setWindowModified(self._unsaved_changes) - - async def _write_active_layout(self) -> list[str] | None: - """Persist the active list; return it on success, None if not saved.""" - active_ids = self.mod_list.active_uuids() - if not await self._ctx.mod_service.save_active_layout(active_ids): - return None - self._saved_active_uuids = list(active_ids) - self._refresh_unsaved_state() - return active_ids - - @asyncSlot() - async def _save_mods_config(self) -> None: - await self._save_active_mods() - - async def _save_active_mods(self) -> bool: - saved = await self._write_active_layout() - if saved is None: - self._toast_manager.warning("Config folder not set") - return False - self._toast_manager.success(f"Saved {len(saved)} active mods") - return True - - @asyncSlot() - async def _launch_game(self) -> None: - if self._header_controller.launchState != "idle": - return - logger.info("Launch requested") - self._header_controller.set_launch_state("launching") - tracking = False - try: - tracking = await self._start_game() - finally: - if not tracking: - self._header_controller.set_launch_state("idle") - - async def _confirm_launch( - self, setting: str, title: str, text: str, info: str, accept: str - ) -> bool: - result, dialog = await await_dialog( - LaunchConfirmDialog, self, title, text, info, accept - ) - if result != QMessageBox.StandardButton.Ok: - return False - if dialog.dont_ask_again(): - cfg = msgspec.structs.replace(self._ctx.config, **{setting: False}) - self._ctx.config_service.save("config.json", cfg) - self._ctx.update_config(cfg) - return True - - async def _start_game(self) -> bool: - launcher = self._ctx.game_launcher - - cfg = self._ctx.config - running = cfg.launch_confirm_running and await asyncio.to_thread( - launcher.is_running - ) - errors = self._ctx.diagnostics_service.active_error_count() - prompts = [ - ( - running, - "launch_confirm_running", - "Game Already Running", - "RimWorld is already running.", - "A second copy can overwrite the first one's saves and settings.", - "Launch Anyway", - ), - ( - cfg.launch_confirm_errors and errors > 0, - "launch_confirm_errors", - "Mod List Has Errors", - f"{errors} active mod(s) have errors.", - "Missing dependencies or wrong load order can crash the game.", - "Launch Anyway", - ), - ( - cfg.launch_confirm_unsaved and self._unsaved_changes, - "launch_confirm_unsaved", - "Unsaved Changes", - "The active mod list has unsaved changes.", - "The list is saved to ModsConfig.xml before the game starts.", - "Save and Launch", - ), - ] - for needed, setting, title, text, info, accept in prompts: - if needed and not await self._confirm_launch( - setting, title, text, info, accept - ): - return False - - if await self._write_active_layout() is None: - logger.warning("Config folder not set — mod list not saved before launch") - self._toast_manager.warning( - "Config folder not set — mod list won't be saved" - ) - - success, msg = await launcher.launch(self._ui_prefs.launch_strategy) - if not success: - logger.warning(msg) - self._toast_manager.warning(msg) - return False - - logger.info(msg) - self._toast_manager.success(msg) - self._launch_task = asyncio.create_task(self._track_game()) - return True - - async def _track_game(self) -> None: - launcher = self._ctx.game_launcher - after = self._ctx.config.after_launch - try: - if not await launcher.wait_for_start(): - self._toast_manager.warning("Game did not start") - return - self._header_controller.set_launch_state("running") - if after == AfterLaunch.CLOSE and not self._unsaved_changes: - self._close_confirmed = True - self.close() - return - if after == AfterLaunch.MINIMIZE: - self.showMinimized() - code = await launcher.wait_for_exit() - if code: - logger.warning("Game exited with code {}", code) - self._toast_manager.warning(f"Game exited with code {code}") - else: - self._toast_manager.info("Game closed") - if after == AfterLaunch.MINIMIZE: - self.showNormal() - self.activateWindow() - finally: - self._header_controller.set_launch_state("idle") - - def _on_strategy_changed(self, index: int) -> None: - new_strategy = LaunchStrategy(index) - if self._ui_prefs.launch_strategy != new_strategy: - logger.info("Launch strategy changed to {}", new_strategy.name) - self._ui_prefs.launch_strategy = new_strategy - save_ui_prefs(self._ui_prefs, self._ctx.config_service) - - @asyncSlot(str) - async def _on_mod_requested(self, uuid: str) -> None: - if self.mod_list.select_uuid(uuid): - return - # Hidden by the search box or a sidebar filter: reset both, then retry. - self.mod_list.search_input.clear() - self.sidebar.select_all_entry() - if not self.mod_list.select_uuid(uuid): - await self._on_mod_selected(uuid) - - @asyncSlot() - async def _on_mod_selected(self, uuid: str) -> None: - self._selected_uuid = uuid or None - if self._selection is None: - return - if uuid: - await self._selection.show(uuid) - else: - self._selection.clear() - - def _apply_current_sidebar_filter(self) -> None: - if self._mods_view is not None: - self._mods_view.apply_current_sidebar_filter() - - def _apply_entry(self, entry: SidebarEntry) -> None: - if self._mods_view is not None: - self._mods_view.apply_sidebar_entry(entry) - - def _on_entry_selected(self, entry: SidebarEntry) -> None: - logger.debug( - "main_window: sidebar entry selected: {}", - entry.label if entry else None, - ) - self._apply_entry(entry) - - def _on_rail_tab_changed(self, index: int) -> None: - logger.debug("main_window: rail tab changed to {}", index) - self._stack.setCurrentIndex(index) - self._header_controller.set_downloads_progress_shown( - not self._views[index].lists_downloads - ) - # Preload an adjacent tab (e.g. the Steam view next to Mods) so its - # content is already warm when the user moves to it. - for adjacent in (index - 1, index + 1): - if 0 <= adjacent < len(self._views): - preload = getattr(self._views[adjacent], "preload", None) - if callable(preload): - preload() - - def _on_rail_hovered(self, index: int) -> None: - # Preload view content (e.g. spin up Chromium for the Steam tab) - # when the user hovers its rail entry, so the first click is fast - # without paying the startup cost up front. - if 0 <= index < len(self._views): - preload = getattr(self._views[index], "preload", None) - if callable(preload): - preload() - - def _snap_rail(self) -> None: - target = ( - RAIL_MAX_WIDTH - if self._rail.width() >= RAIL_COLLAPSE_WIDTH - else RAIL_MIN_WIDTH - ) - self._splitter.setSizes([target, self._splitter.width() - target]) - collapsed = target == RAIL_MIN_WIDTH - if self._ui_prefs.rail_collapsed != collapsed: - self._ui_prefs.rail_collapsed = collapsed - save_ui_prefs(self._ui_prefs, self._ctx.config_service) - - # ── Diagnostics summary callback ───────────────────────── - - def _on_diagnostics_summary_changed( - self, diagnostics: dict[str, ModDiagnosticsView] - ) -> None: - self._apply_current_sidebar_filter() - if self._mods_view is not None and self._selected_uuid is not None: - self.mod_info.set_issues( - self._ctx.diagnostics_service.issues_for(self._selected_uuid) - ) - - # ── Resize ─────────────────────────────────────────────── - - def resizeEvent(self, event: QResizeEvent) -> None: - super().resizeEvent(event) - if hasattr(self, "_toast_manager"): - self._toast_manager.resize_to_parent() - - def changeEvent(self, event: QEvent) -> None: - super().changeEvent(event) - if event.type() == QEvent.Type.WindowStateChange: - self._header_controller.set_maximized(self.isMaximized()) diff --git a/src/pxmodrim/ui/window/menu_bar.py b/src/pxmodrim/ui/window/menu_bar.py deleted file mode 100644 index 8a83aa6..0000000 --- a/src/pxmodrim/ui/window/menu_bar.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from collections.abc import Mapping - -from PySide6.QtGui import QAction -from PySide6.QtWidgets import QMenuBar, QWidget - -from pxmodrim.ui.window.actions import ActionId - -MENU_LAYOUT: tuple[tuple[str, tuple[ActionId | None, ...]], ...] = ( - ( - "&File", - ( - ActionId.SAVE, - ActionId.RESTORE, - ActionId.SETTINGS, - None, - ActionId.QUIT, - ), - ), - ( - "&Mods", - ( - ActionId.REFRESH, - ActionId.FULL_RESCAN, - ActionId.AUTO_SORT, - None, - ActionId.UPDATE_MODS, - ), - ), - ( - "&View", - ( - ActionId.FOCUS_SEARCH, - ActionId.NEXT_VIEW, - ActionId.PREV_VIEW, - None, - ActionId.FULLSCREEN, - ), - ), - ( - "&Help", - ( - ActionId.REPORT_ISSUE, - ActionId.UPLOAD_LOGS, - ActionId.OPEN_LOGS, - ActionId.SHORTCUTS, - ActionId.CHECK_UPDATES, - None, - ActionId.ABOUT, - ), - ), -) - - -class MenuBar(QMenuBar): - def __init__( - self, actions: Mapping[ActionId, QAction], parent: QWidget | None = None - ) -> None: - super().__init__(parent) - for title, action_ids in MENU_LAYOUT: - menu = self.addMenu(title) - for action_id in action_ids: - if action_id is None: - menu.addSeparator() - else: - menu.addAction(actions[action_id]) diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt new file mode 100644 index 0000000..ca4c558 --- /dev/null +++ b/src/ui/CMakeLists.txt @@ -0,0 +1,53 @@ +# Every C++ source/header, every QML file and the embedded resources are globbed, so +# new UI files need no CMake edit. QML files must sit directly in this directory +# (the module root): Qt does not reliably register types from subfolders, and plain +# type names only resolve within one flat module. + +file(GLOB_RECURSE PX_UI_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp) +file(GLOB_RECURSE PX_UI_HEADERS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.hpp) +file(GLOB PX_UI_QML CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*.qml ${CMAKE_CURRENT_SOURCE_DIR}/*.js) + +# Resources are addressed relative to this directory: the stylesheet is +# :/qt/qml/PxModRim/theme/style.qss, logos are :/qt/qml/PxModRim/assets/*, and a +# plugin's injected script/style is :/qt/qml/PxModRim/plugins//inject.*. +file(GLOB_RECURSE PX_UI_RESOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/theme/*.qss + ${CMAKE_CURRENT_SOURCE_DIR}/assets/* + ${CMAKE_CURRENT_SOURCE_DIR}/plugins/*/inject.css + ${CMAKE_CURRENT_SOURCE_DIR}/plugins/*/inject.js) + +# The Steam Workshop browser imports QtWebEngine. Without WebEngine it cannot be +# compiled as QML, so it ships as a plain resource and is loaded only when enabled. +set(PX_UI_WEB_QML SteamWorkshop.qml) +set(PX_UI_HAS_WEBENGINE OFF) +if(TARGET Qt6::WebEngineQuick) + set(PX_UI_HAS_WEBENGINE ON) +endif() +if(NOT PX_UI_HAS_WEBENGINE) + list(FILTER PX_UI_QML EXCLUDE REGEX "/${PX_UI_WEB_QML}$") + if(EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/${PX_UI_WEB_QML}) + list(APPEND PX_UI_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/${PX_UI_WEB_QML}) + endif() +endif() + +list(TRANSFORM PX_UI_RESOURCES REPLACE "^${CMAKE_CURRENT_SOURCE_DIR}/" "") +list(TRANSFORM PX_UI_QML REPLACE "^${CMAKE_CURRENT_SOURCE_DIR}/" "") + +qt_add_library(pxui STATIC) +qt_add_qml_module(pxui + URI PxModRim + VERSION 1.0 + QML_FILES ${PX_UI_QML} + SOURCES ${PX_UI_SOURCES} ${PX_UI_HEADERS} + RESOURCES ${PX_UI_RESOURCES} +) +target_include_directories(pxui PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) +target_link_libraries(pxui PUBLIC + pxcore + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 Qt6::QuickWidgets + Qt6::Svg Qt6::Widgets + QCoro6::Core QCoro6::Qml QCoro6::Quick) +if(PX_UI_HAS_WEBENGINE) + target_link_libraries(pxui PUBLIC Qt6::WebEngineQuick) + target_compile_definitions(pxui PUBLIC PX_HAS_WEBENGINE=1) +endif() diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml b/src/ui/CatalogCard.qml similarity index 99% rename from src/pxmodrim/ui/plugins/workshop/CatalogCard.qml rename to src/ui/CatalogCard.qml index 825aea7..f1476b5 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/ui/CatalogCard.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: card diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogImage.qml b/src/ui/CatalogImage.qml similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/CatalogImage.qml rename to src/ui/CatalogImage.qml diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml b/src/ui/CatalogSettings.qml similarity index 96% rename from src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml rename to src/ui/CatalogSettings.qml index 9aa0df2..c67c66b 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogSettings.qml +++ b/src/ui/CatalogSettings.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import "../../components/controls" +import PxModRim ColumnLayout { id: root diff --git a/src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml b/src/ui/CatalogThumb.qml similarity index 97% rename from src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml rename to src/ui/CatalogThumb.qml index 7fb0a9e..5e7275f 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogThumb.qml +++ b/src/ui/CatalogThumb.qml @@ -1,5 +1,5 @@ import QtQuick -import "../../components/controls" +import PxModRim Rectangle { id: thumb diff --git a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml b/src/ui/CollectionBanner.qml similarity index 99% rename from src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml rename to src/ui/CollectionBanner.qml index bdaab60..185beb0 100644 --- a/src/pxmodrim/ui/plugins/workshop/CollectionBanner.qml +++ b/src/ui/CollectionBanner.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: banner diff --git a/src/pxmodrim/ui/components/controls/DiagnosticBadge.qml b/src/ui/DiagnosticBadge.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/DiagnosticBadge.qml rename to src/ui/DiagnosticBadge.qml diff --git a/src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml b/src/ui/DownloadSidebar.qml similarity index 99% rename from src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml rename to src/ui/DownloadSidebar.qml index 5c9c267..a7c1721 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml +++ b/src/ui/DownloadSidebar.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: root diff --git a/src/pxmodrim/ui/plugins/downloads/Downloads.qml b/src/ui/Downloads.qml similarity index 96% rename from src/pxmodrim/ui/plugins/downloads/Downloads.qml rename to src/ui/Downloads.qml index 635aacf..a1749bb 100644 --- a/src/pxmodrim/ui/plugins/downloads/Downloads.qml +++ b/src/ui/Downloads.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: root @@ -130,7 +130,7 @@ Rectangle { } Connections { target: downloadsModel - function onSpeed_changed() { graph.requestPaint() } + function onSpeedChanged() { graph.requestPaint() } } } @@ -149,9 +149,9 @@ Rectangle { anchors.bottom: parent.bottom anchors.margins: 10 visible: text.length > 0 - text: downloadsModel.phase === "login" ? "Logging in to Steam\u2026" + text: downloadsModel.phase === "login" ? "Connecting\u2026" : downloadsModel.phase === "query" - ? "Querying mod info from Steam\u2026 " + downloadsModel.resolved + " / " + downloadsModel.total + ? "Querying mod info\u2026 " + downloadsModel.resolved + " / " + downloadsModel.total : "" color: Theme.textMuted font.family: Theme.fontFamily diff --git a/src/pxmodrim/ui/components/controls/EmptyState.qml b/src/ui/EmptyState.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/EmptyState.qml rename to src/ui/EmptyState.qml diff --git a/src/pxmodrim/ui/plugins/organizer/ErrorBanner.qml b/src/ui/ErrorBanner.qml similarity index 100% rename from src/pxmodrim/ui/plugins/organizer/ErrorBanner.qml rename to src/ui/ErrorBanner.qml diff --git a/src/pxmodrim/ui/components/FilterSidebar.qml b/src/ui/FilterSidebar.qml similarity index 99% rename from src/pxmodrim/ui/components/FilterSidebar.qml rename to src/ui/FilterSidebar.qml index 47c90c6..b8d346a 100644 --- a/src/pxmodrim/ui/components/FilterSidebar.qml +++ b/src/ui/FilterSidebar.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "controls" +import PxModRim // Shared filter list (Mods and Organizer). The model exposes: filterLabel, count, // iconName, sectionName and optionally iconColor, badgeBg, badgeFg. diff --git a/src/pxmodrim/ui/components/Header.qml b/src/ui/Header.qml similarity index 98% rename from src/pxmodrim/ui/components/Header.qml rename to src/ui/Header.qml index 266e6f0..a3d8c6d 100644 --- a/src/pxmodrim/ui/components/Header.qml +++ b/src/ui/Header.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "controls" +import PxModRim Rectangle { id: root @@ -231,7 +231,7 @@ Rectangle { Row { Layout.alignment: Qt.AlignRight spacing: 6 - visible: root.controller.is_frameless + visible: root.controller.isFrameless PxButton { variant: "ghost" @@ -363,6 +363,6 @@ Rectangle { onPressed: root.controller.dragStarted() onDoubleClicked: root.controller.maximize() - cursorShape: root.controller.is_frameless ? Qt.OpenHandCursor : Qt.ArrowCursor + cursorShape: root.controller.isFrameless ? Qt.OpenHandCursor : Qt.ArrowCursor } } diff --git a/src/pxmodrim/ui/components/IconRail.qml b/src/ui/IconRail.qml similarity index 99% rename from src/pxmodrim/ui/components/IconRail.qml rename to src/ui/IconRail.qml index b2cdfcd..255b8ff 100644 --- a/src/pxmodrim/ui/components/IconRail.qml +++ b/src/ui/IconRail.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "controls" +import PxModRim Rectangle { id: root diff --git a/src/ui/Main.qml b/src/ui/Main.qml new file mode 100644 index 0000000..e792ecb --- /dev/null +++ b/src/ui/Main.qml @@ -0,0 +1,14 @@ +import QtQuick +import QtQuick.Controls + +ApplicationWindow { + visible: true + width: 640 + height: 360 + title: "PxModRim" + + Label { + anchors.centerIn: parent + text: "PxModRim" + } +} diff --git a/src/pxmodrim/ui/components/controls/ModDescription.qml b/src/ui/ModDescription.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/ModDescription.qml rename to src/ui/ModDescription.qml diff --git a/src/pxmodrim/ui/panels/ModInfo.qml b/src/ui/ModInfo.qml similarity index 99% rename from src/pxmodrim/ui/panels/ModInfo.qml rename to src/ui/ModInfo.qml index 2f27af8..e6992d1 100644 --- a/src/pxmodrim/ui/panels/ModInfo.qml +++ b/src/ui/ModInfo.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../components/controls" +import PxModRim Rectangle { id: root diff --git a/src/pxmodrim/ui/panels/ModList.qml b/src/ui/ModList.qml similarity index 99% rename from src/pxmodrim/ui/panels/ModList.qml rename to src/ui/ModList.qml index c60af00..60b2595 100644 --- a/src/pxmodrim/ui/panels/ModList.qml +++ b/src/ui/ModList.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Controls -import "../components/controls" +import PxModRim Rectangle { id: root diff --git a/src/pxmodrim/ui/plugins/workshop/ModRow.qml b/src/ui/ModRow.qml similarity index 98% rename from src/pxmodrim/ui/plugins/workshop/ModRow.qml rename to src/ui/ModRow.qml index 5be102d..d721521 100644 --- a/src/pxmodrim/ui/plugins/workshop/ModRow.qml +++ b/src/ui/ModRow.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: row diff --git a/src/pxmodrim/ui/plugins/organizer/ModTree.qml b/src/ui/ModTree.qml similarity index 92% rename from src/pxmodrim/ui/plugins/organizer/ModTree.qml rename to src/ui/ModTree.qml index 5005d34..25f0adc 100644 --- a/src/pxmodrim/ui/plugins/organizer/ModTree.qml +++ b/src/ui/ModTree.qml @@ -1,15 +1,15 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: root color: Theme.elevate1 clip: true - property int anchorRow: -1 - property int focusedRow: -1 + property string anchorKey: "" + property string focusedKey: "" property int dropRow: -1 property var dropTarget: null @@ -22,8 +22,8 @@ Rectangle { function collapseAll() { tree.collapseRecursively() - root.anchorRow = -1 - root.focusedRow = -1 + root.anchorKey = "" + root.focusedKey = "" organizerPanel.persistAllCollapsed(true) } @@ -38,28 +38,30 @@ Rectangle { function clickRow(index, row, modifiers) { var range = [] - if ((modifiers & Qt.ShiftModifier) && root.anchorRow >= 0) { - var from = Math.min(root.anchorRow, row) - var to = Math.max(root.anchorRow, row) + var key = modTreeModel.data(index, Qt.UserRole + 2) + var anchorRow = tree.rowAtIndex(modTreeModel.indexForKey(root.anchorKey)) + if ((modifiers & Qt.ShiftModifier) && anchorRow >= 0) { + var from = Math.min(anchorRow, row) + var to = Math.max(anchorRow, row) for (var r = from; r <= to; ++r) range.push(tree.index(r, 0)) } else { - root.anchorRow = row + root.anchorKey = key } - root.focusedRow = row + root.focusedKey = key organizerPanel.selectRow(index, modifiers, range) tree.forceActiveFocus() } function toggleRow(index, row) { - if (tree.isExpanded(row)) { + var collapsed = tree.isExpanded(row) + if (collapsed) tree.collapse(row) - organizerPanel.persistCollapsed(index, true) - } else { + else tree.expand(row) - organizerPanel.persistCollapsed(index, false) + organizerPanel.persistCollapsed(index, collapsed) + if (!collapsed) root.restoreExpansion() - } } function navigateRow(row, modifiers) { @@ -70,9 +72,10 @@ Rectangle { } function activeRow() { - if (root.focusedRow >= 0 && root.focusedRow < tree.rows) - return root.focusedRow - return -1 + var row = tree.rowAtIndex(modTreeModel.indexForKey(root.focusedKey)) + if (row < 0) + root.focusedKey = "" + return row } property int modelRevision: 0 @@ -93,8 +96,8 @@ Rectangle { all.push(tree.index(r, 0)) organizerPanel.selectRow(all[0], Qt.NoModifier, []) organizerPanel.selectRow(all[all.length - 1], Qt.ShiftModifier, all) - root.anchorRow = 0 - root.focusedRow = tree.rows - 1 + root.anchorKey = modTreeModel.data(all[0], Qt.UserRole + 2) + root.focusedKey = modTreeModel.data(all[all.length - 1], Qt.UserRole + 2) } Connections { @@ -104,9 +107,9 @@ Rectangle { function onRowsRemoved() { root.modelRevision++ } function onModelReset() { root.modelRevision++ - root.anchorRow = -1 + root.anchorKey = "" Qt.callLater(root.restoreExpansion) - root.focusedRow = -1 + root.focusedKey = "" } } @@ -174,10 +177,9 @@ Rectangle { implicitWidth: tree.width implicitHeight: item.model.kind === "mod" ? 30 : 32 radius: Theme.radiusSm - color: root.dropRow === item.row + color: root.dropRow === item.row || item.model.selected ? Theme.primaryBg - : (item.model.selected ? Theme.primaryBg - : (rowMouse.containsMouse ? Theme.elevate3 : "transparent")) + : rowMouse.containsMouse ? Theme.elevate3 : "transparent" border.width: root.dropRow === item.row ? 1 : 0 border.color: Theme.primary Accessible.role: Accessible.TreeItem diff --git a/src/pxmodrim/ui/components/controls/PxBadge.qml b/src/ui/PxBadge.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxBadge.qml rename to src/ui/PxBadge.qml diff --git a/src/pxmodrim/ui/components/controls/PxButton.qml b/src/ui/PxButton.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxButton.qml rename to src/ui/PxButton.qml diff --git a/src/pxmodrim/ui/components/controls/PxCheckBox.qml b/src/ui/PxCheckBox.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxCheckBox.qml rename to src/ui/PxCheckBox.qml diff --git a/src/pxmodrim/ui/components/controls/PxCheckIndicator.qml b/src/ui/PxCheckIndicator.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxCheckIndicator.qml rename to src/ui/PxCheckIndicator.qml diff --git a/src/pxmodrim/ui/components/controls/PxComboBox.qml b/src/ui/PxComboBox.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxComboBox.qml rename to src/ui/PxComboBox.qml diff --git a/src/pxmodrim/ui/components/controls/PxDialog.qml b/src/ui/PxDialog.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxDialog.qml rename to src/ui/PxDialog.qml diff --git a/src/pxmodrim/ui/components/controls/PxDialogFooter.qml b/src/ui/PxDialogFooter.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxDialogFooter.qml rename to src/ui/PxDialogFooter.qml diff --git a/src/pxmodrim/ui/components/controls/PxMenu.qml b/src/ui/PxMenu.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxMenu.qml rename to src/ui/PxMenu.qml diff --git a/src/pxmodrim/ui/components/controls/PxMenuItem.qml b/src/ui/PxMenuItem.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxMenuItem.qml rename to src/ui/PxMenuItem.qml diff --git a/src/pxmodrim/ui/components/controls/PxProgressBar.qml b/src/ui/PxProgressBar.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxProgressBar.qml rename to src/ui/PxProgressBar.qml diff --git a/src/pxmodrim/ui/components/controls/PxScrollBar.qml b/src/ui/PxScrollBar.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxScrollBar.qml rename to src/ui/PxScrollBar.qml diff --git a/src/pxmodrim/ui/components/controls/PxSpinBox.qml b/src/ui/PxSpinBox.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxSpinBox.qml rename to src/ui/PxSpinBox.qml diff --git a/src/pxmodrim/ui/components/controls/PxTextField.qml b/src/ui/PxTextField.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxTextField.qml rename to src/ui/PxTextField.qml diff --git a/src/pxmodrim/ui/components/controls/PxToolTip.qml b/src/ui/PxToolTip.qml similarity index 100% rename from src/pxmodrim/ui/components/controls/PxToolTip.qml rename to src/ui/PxToolTip.qml diff --git a/src/pxmodrim/ui/plugins/organizer/RuleEditor.qml b/src/ui/RuleEditor.qml similarity index 96% rename from src/pxmodrim/ui/plugins/organizer/RuleEditor.qml rename to src/ui/RuleEditor.qml index f18ba7e..3ab2b19 100644 --- a/src/pxmodrim/ui/plugins/organizer/RuleEditor.qml +++ b/src/ui/RuleEditor.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim FocusScope { id: root @@ -75,27 +75,15 @@ FocusScope { } function findFieldIndex(val) { - for (var i = 0; i < fieldOptions.length; ++i) { - if (fieldOptions[i].value === val) - return i - } - return 0 + return Math.max(0, fieldOptions.findIndex(option => option.value === val)) } function findOpIndex(val) { - for (var i = 0; i < opOptions.length; ++i) { - if (opOptions[i].value === val) - return i - } - return 0 + return Math.max(0, opOptions.findIndex(option => option.value === val)) } function findFolderIndex(fid) { - for (var i = 0; i < ruleFolders.length; ++i) { - if (ruleFolders[i].id === fid) - return i - } - return 0 + return Math.max(0, ruleFolders.findIndex(folder => folder.id === fid)) } ListModel { @@ -126,9 +114,7 @@ FocusScope { } onRuleRowsChanged: { - if (!opened && !draftMatches(ruleRows)) - loadDraft() - else if (opened && draftMatches(loadedRows) && !draftMatches(ruleRows)) + if ((!opened || draftMatches(loadedRows)) && !draftMatches(ruleRows)) loadDraft() refreshDirty() schedulePreview() diff --git a/src/pxmodrim/ui/panels/Settings.qml b/src/ui/Settings.qml similarity index 99% rename from src/pxmodrim/ui/panels/Settings.qml rename to src/ui/Settings.qml index b5e9a19..9edcbc4 100644 --- a/src/pxmodrim/ui/panels/Settings.qml +++ b/src/ui/Settings.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../components/controls" +import PxModRim Rectangle { id: root diff --git a/src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml b/src/ui/SteamSettings.qml similarity index 99% rename from src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml rename to src/ui/SteamSettings.qml index 947bff0..42bacbd 100644 --- a/src/pxmodrim/ui/plugins/steam_downloader/SteamSettings.qml +++ b/src/ui/SteamSettings.qml @@ -1,6 +1,6 @@ import QtQuick import QtQuick.Layouts -import "../../components/controls" +import PxModRim ColumnLayout { id: root diff --git a/src/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml b/src/ui/SteamWorkshop.qml similarity index 86% rename from src/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml rename to src/ui/SteamWorkshop.qml index 68f5f33..93801db 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml +++ b/src/ui/SteamWorkshop.qml @@ -3,7 +3,7 @@ import QtQuick.Controls import QtQuick.Layouts import QtWebEngine import QtWebChannel -import "../../components/controls" +import PxModRim Rectangle { id: root @@ -15,6 +15,10 @@ Rectangle { property bool _loadedOnce: false property bool _pendingReload: false + function runScript(code) { + webView.runJavaScript(code, WebEngineScript.MainWorld) + } + function _navigate() { if (!root._loadedOnce) { root._loadedOnce = true @@ -131,9 +135,17 @@ Rectangle { WebEngineView { id: webView objectName: "workshopWeb" - webChannel: channel + webChannel: steamWorkshopPanel.isSteamOrigin(url) ? channel : null anchors.fill: parent + onNavigationRequested: function(request) { + if (!request.isMainFrame || steamWorkshopPanel.isSteamOrigin(request.url)) + return + request.action = WebEngineView.IgnoreRequest + if (request.url.toString() !== "about:blank") + Qt.openUrlExternally(request.url) + } + profile: WebEngineProfile { id: steamProfile storageName: "pxmodrim-steam" @@ -157,13 +169,15 @@ Rectangle { } Component.onCompleted: { - var inj = WebEngine.script() - inj.name = "inject" - inj.sourceCode = _injectCode - inj.injectionPoint = WebEngineScript.DocumentCreation - inj.worldId = WebEngineScript.MainWorld - inj.runsOnSubFrames = false - webView.userScripts.insert(inj) + if (_injectCode.length > 0) { + var inj = WebEngine.script() + inj.name = "inject" + inj.sourceCode = _injectCode + inj.injectionPoint = WebEngineScript.DocumentCreation + inj.worldId = WebEngineScript.MainWorld + inj.runsOnSubFrames = false + webView.userScripts.insert(inj) + } webView.profile.clearHttpCache() } diff --git a/src/pxmodrim/ui/plugins/organizer/TagEditor.qml b/src/ui/TagEditor.qml similarity index 99% rename from src/pxmodrim/ui/plugins/organizer/TagEditor.qml rename to src/ui/TagEditor.qml index 9e820d9..a6e149e 100644 --- a/src/pxmodrim/ui/plugins/organizer/TagEditor.qml +++ b/src/ui/TagEditor.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim PxDialog { id: editor diff --git a/src/pxmodrim/ui/plugins/workshop/TagPicker.qml b/src/ui/TagPicker.qml similarity index 93% rename from src/pxmodrim/ui/plugins/workshop/TagPicker.qml rename to src/ui/TagPicker.qml index c576549..8715f79 100644 --- a/src/pxmodrim/ui/plugins/workshop/TagPicker.qml +++ b/src/ui/TagPicker.qml @@ -1,16 +1,19 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim PxComboBox { id: control property var options: [] property string selectedTag: "" displayText: selectedTag || "All tags" - model: options.filter(function (tag) { - return tag === "All tags" || tag.toLowerCase().indexOf(tagSearch.text.toLowerCase()) >= 0 - }) + model: { + const query = tagSearch.text.toLowerCase() + return options.filter(function (tag) { + return tag === "All tags" || tag.toLowerCase().indexOf(query) >= 0 + }) + } Accessible.name: "Tag filter" onActivated: selectedTag = currentText === "All tags" ? "" : currentText delegate: ItemDelegate { diff --git a/src/pxmodrim/ui/panels/TimeAnalytics.qml b/src/ui/TimeAnalytics.qml similarity index 99% rename from src/pxmodrim/ui/panels/TimeAnalytics.qml rename to src/ui/TimeAnalytics.qml index bff43ed..7856041 100644 --- a/src/pxmodrim/ui/panels/TimeAnalytics.qml +++ b/src/ui/TimeAnalytics.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../components/controls" +import PxModRim Rectangle { id: root diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/ui/Workshop.qml similarity index 99% rename from src/pxmodrim/ui/plugins/workshop/Workshop.qml rename to src/ui/Workshop.qml index 8528bc8..032f010 100644 --- a/src/pxmodrim/ui/plugins/workshop/Workshop.qml +++ b/src/ui/Workshop.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts -import "../../components/controls" +import PxModRim Rectangle { id: root diff --git a/src/pxmodrim/ui/assets/logo.png b/src/ui/assets/logo.png similarity index 100% rename from src/pxmodrim/ui/assets/logo.png rename to src/ui/assets/logo.png diff --git a/src/pxmodrim/ui/assets/logo.svg b/src/ui/assets/logo.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo.svg rename to src/ui/assets/logo.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_1.svg b/src/ui/assets/logo_nobg_1.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_1.svg rename to src/ui/assets/logo_nobg_1.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_2.svg b/src/ui/assets/logo_nobg_2.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_2.svg rename to src/ui/assets/logo_nobg_2.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_3.svg b/src/ui/assets/logo_nobg_3.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_3.svg rename to src/ui/assets/logo_nobg_3.svg diff --git a/src/pxmodrim/ui/assets/logo_nobg_4.svg b/src/ui/assets/logo_nobg_4.svg similarity index 100% rename from src/pxmodrim/ui/assets/logo_nobg_4.svg rename to src/ui/assets/logo_nobg_4.svg diff --git a/src/ui/components/banner_widget.cpp b/src/ui/components/banner_widget.cpp new file mode 100644 index 0000000..7a9713b --- /dev/null +++ b/src/ui/components/banner_widget.cpp @@ -0,0 +1,134 @@ +#include "ui/components/banner_widget.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +constexpr int kFallbackWidth = 300; +constexpr int kOverlayPad = 16; + +// Floor division by two, so a negative offset rounds the way the legacy `//` +// did. +int floorHalf(int value) { + return value >= 0 ? value / 2 : -((-value + 1) / 2); +} + +} // namespace + +AspectRatioBanner::AspectRatioBanner(QWidget* parent, int maxHeight) + : QWidget(parent), m_maxHeight(maxHeight) { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void AspectRatioBanner::setPixmap(const QPixmap& pixmap) { + m_pixmap = pixmap; + recomputeScaledPixmap(); + updateGeometry(); + update(); +} + +void AspectRatioBanner::setTitle(const QString& title) { + m_title = title; + update(); +} + +void AspectRatioBanner::setSubtitle(const QString& subtitle) { + m_subtitle = subtitle; + update(); +} + +void AspectRatioBanner::setShowOverlay(bool show) { + m_showOverlay = show; + update(); +} + +bool AspectRatioBanner::hasHeightForWidth() const { return true; } + +int AspectRatioBanner::heightForWidth(int w) const { + return std::min(w * kBannerAspectH / kBannerAspectW, m_maxHeight); +} + +QSize AspectRatioBanner::sizeHint() const { + const int w = width() > 0 ? width() : kFallbackWidth; + return QSize(w, heightForWidth(w)); +} + +void AspectRatioBanner::recomputeScaledPixmap() { + if (m_pixmap.isNull() || width() <= 0) { + m_scaledPixmap = QPixmap(); + return; + } + + m_scaledPixmap = + m_pixmap.scaled(width(), heightForWidth(width()), + Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); +} + +void AspectRatioBanner::paintEvent(QPaintEvent*) { + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setClipRect(rect()); + + if (m_scaledPixmap.isNull()) { + painter.fillRect(rect(), panelBgQ()); + } else { + const int x = floorHalf(width() - m_scaledPixmap.width()); + const int y = floorHalf(height() - m_scaledPixmap.height()); + painter.drawPixmap(x, y, m_scaledPixmap); + } + + if (m_title.isEmpty() || !m_showOverlay) { + return; + } + + const int overlayTop = height() - kBannerOverlayHeight; + QLinearGradient gradient(0, overlayTop, 0, height()); + gradient.setColorAt(0.0, QColor(0, 0, 0, 0)); + gradient.setColorAt(1.0, QColor(0, 0, 0, 180)); + painter.fillRect(0, overlayTop, width(), kBannerOverlayHeight, gradient); + + const int textAreaTop = overlayTop + 12; + const int textAreaW = width() - kOverlayPad * 2; + + QFont font = painter.font(); + font.setPointSize(18); + font.setBold(true); + painter.setFont(font); + painter.setPen(textMainQ()); + + int titleH = kBannerOverlayHeight - 24; + if (!m_subtitle.isEmpty()) { + titleH = titleH * 3 / 5; + } + painter.drawText(kOverlayPad, textAreaTop, textAreaW, titleH, + Qt::AlignLeft | Qt::AlignBottom, m_title); + + if (!m_subtitle.isEmpty()) { + font.setPointSize(12); + font.setBold(false); + painter.setFont(font); + painter.setPen(textMutedQ()); + const int subtitleTop = textAreaTop + titleH + 2; + const int subtitleH = kBannerOverlayHeight - titleH - 26; + painter.drawText(kOverlayPad, subtitleTop, textAreaW, subtitleH, + Qt::AlignLeft | Qt::AlignTop, m_subtitle); + } +} + +void AspectRatioBanner::resizeEvent(QResizeEvent* event) { + recomputeScaledPixmap(); + QWidget::resizeEvent(event); +} + +} // namespace px::modrim diff --git a/src/ui/components/banner_widget.hpp b/src/ui/components/banner_widget.hpp new file mode 100644 index 0000000..be9801a --- /dev/null +++ b/src/ui/components/banner_widget.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include +#include +#include + +#include "ui/theme/constants.hpp" + +class QPaintEvent; +class QResizeEvent; + +namespace px::modrim { + +// Fixed 16:9 banner (capped at max height) that crops the pixmap to fill it, +// with an optional title and subtitle over a gradient at the bottom. +class AspectRatioBanner : public QWidget { + Q_OBJECT + + public: + explicit AspectRatioBanner(QWidget* parent = nullptr, + int maxHeight = kBannerMaxHeight); + + void setPixmap(const QPixmap& pixmap); + void setTitle(const QString& title); + void setSubtitle(const QString& subtitle); + void setShowOverlay(bool show); + + bool hasHeightForWidth() const override; + int heightForWidth(int width) const override; + QSize sizeHint() const override; + + protected: + void paintEvent(QPaintEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + + private: + void recomputeScaledPixmap(); + + QPixmap m_pixmap; + QPixmap m_scaledPixmap; + QString m_title; + QString m_subtitle; + int m_maxHeight; + bool m_showOverlay = true; +}; + +} // namespace px::modrim diff --git a/src/ui/components/button.cpp b/src/ui/components/button.cpp new file mode 100644 index 0000000..f2f628b --- /dev/null +++ b/src/ui/components/button.cpp @@ -0,0 +1,13 @@ +#include "ui/components/button.hpp" + +#include + +namespace px::modrim { + +AppButton::AppButton(const QString& text, QWidget* parent) + : QPushButton(text, parent) { + setObjectName(QStringLiteral("appBtn")); + setCursor(Qt::PointingHandCursor); +} + +} // namespace px::modrim diff --git a/src/ui/components/button.hpp b/src/ui/components/button.hpp new file mode 100644 index 0000000..6cecaf4 --- /dev/null +++ b/src/ui/components/button.hpp @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +class QWidget; + +namespace px::modrim { + +// Push button in the application's standard look (QSS `appBtn`) with a +// pointing-hand cursor. +class AppButton : public QPushButton { + public: + explicit AppButton(const QString& text = {}, QWidget* parent = nullptr); +}; + +} // namespace px::modrim diff --git a/src/ui/components/components.hpp b/src/ui/components/components.hpp new file mode 100644 index 0000000..c3d130c --- /dev/null +++ b/src/ui/components/components.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "ui/components/banner_widget.hpp" +#include "ui/components/button.hpp" +#include "ui/components/header_controller.hpp" +#include "ui/components/header_panel.hpp" +#include "ui/components/icon_button.hpp" +#include "ui/components/icons.hpp" +#include "ui/components/procedural_preview.hpp" +#include "ui/components/progress_dialog.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/components/toast.hpp" +#include "ui/components/view_rail_panel.hpp" diff --git a/src/ui/components/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp new file mode 100644 index 0000000..b013a65 --- /dev/null +++ b/src/ui/components/dialog_chrome.cpp @@ -0,0 +1,189 @@ +#include "ui/components/dialog_chrome.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +constexpr int kTitleBarHeight = 36; +constexpr int kBorder = 1; +constexpr int kGripSize = 16; +constexpr int kCloseSize = 26; + +// Every icon name used here is in the icon table; a miss is a programming +// error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +// Soft-red close button that fills solid on hover, like the main window's. +class CloseButton : public QPushButton { + public: + explicit CloseButton(QWidget* parent) : QPushButton(parent) { + setObjectName(QStringLiteral("dialogCloseBtn")); + setFixedSize(kCloseSize, kCloseSize); + // A global QPushButton min-height would otherwise beat setFixedSize. + constexpr int kInner = + kCloseSize - 2; // the 1px QSS border sits outside the content box + setStyleSheet(QStringLiteral("min-width: %1px; max-width: %1px; " + "min-height: %1px; max-height: %1px;") + .arg(kInner)); + setToolTip(QStringLiteral("Close")); + setCursor(Qt::PointingHandCursor); + setFocusPolicy(Qt::NoFocus); + m_normalIcon = requireIcon(u"close", 12, paletteValue(u"DANGER")); + m_hoverIcon = requireIcon(u"close", 12, paletteValue(u"TEXT_ON_ACCENT")); + setIcon(m_normalIcon); + } + + protected: + void enterEvent(QEnterEvent* event) override { + setIcon(m_hoverIcon); + QPushButton::enterEvent(event); + } + + void leaveEvent(QEvent* event) override { + setIcon(m_normalIcon); + QPushButton::leaveEvent(event); + } + + private: + QIcon m_normalIcon; + QIcon m_hoverIcon; +}; + +// Closes the dialog the way Escape does: a message box clicks its escape button +// (or Cancel), anything else rejects. +void closeLikeEscape(QDialog* dialog) { + if (auto* box = qobject_cast(dialog)) { + if (QAbstractButton* button = box->escapeButton()) { + button->click(); + return; + } + if (box->button(QMessageBox::Cancel) != nullptr) { + box->done(static_cast(QMessageBox::Cancel)); + return; + } + } + dialog->reject(); +} + +class TitleBar : public QWidget { + public: + TitleBar(QDialog* dialog, bool closable) : QWidget(dialog) { + setObjectName(QStringLiteral("dialogTitleBar")); + setAttribute(Qt::WA_StyledBackground, true); + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(14, 0, 6, 0); + layout->setSpacing(6); + + auto* title = new QLabel(dialog->windowTitle(), this); + title->setObjectName(QStringLiteral("dialogTitleText")); + layout->addWidget(title, 1); + connect(dialog, &QWidget::windowTitleChanged, title, &QLabel::setText); + + if (closable) { + auto* close = new CloseButton(this); + connect(close, &QPushButton::clicked, dialog, + [dialog] { closeLikeEscape(dialog); }); + layout->addWidget(close); + } + } + + protected: + void mousePressEvent(QMouseEvent* event) override { + if (event->button() == Qt::LeftButton) { + if (QWindow* handle = window()->windowHandle()) { + handle->startSystemMove(); + event->accept(); + return; + } + } + QWidget::mousePressEvent(event); + } +}; + +// Keeps the overlay title bar and size grip pinned to the dialog frame. +class Chrome : public QObject { + public: + Chrome(QDialog* dialog, bool closable, bool resizable) + : QObject(dialog), m_dialog(dialog) { + m_bar = new TitleBar(dialog, closable); + m_grip = resizable ? new QSizeGrip(dialog) : nullptr; + dialog->installEventFilter(this); + place(); + } + + protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (watched == m_dialog && + (event->type() == QEvent::Resize || event->type() == QEvent::Show)) { + place(); + } + return false; + } + + private: + void place() { + const int width = m_dialog->width(); + m_bar->setGeometry(kBorder, kBorder, width - 2 * kBorder, kTitleBarHeight); + m_bar->raise(); + if (m_grip) { + m_grip->setGeometry(width - kGripSize - kBorder, + m_dialog->height() - kGripSize - kBorder, kGripSize, + kGripSize); + m_grip->raise(); + } + } + + QDialog* m_dialog; + TitleBar* m_bar = nullptr; + QSizeGrip* m_grip = nullptr; +}; + +} // namespace + +void installDialogChrome(QDialog* dialog, bool closable, + std::optional resizable) { + if (qobject_cast(dialog) || + dialog->property("chromed").toBool()) { + return; + } + const bool grip = resizable.value_or( + !(qobject_cast(dialog) || dialog->isSizeGripEnabled())); + + const Qt::WindowFlags keep = dialog->windowFlags() & Qt::WindowStaysOnTopHint; + dialog->setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | keep); + dialog->setProperty("chromed", true); + + if (QLayout* layout = dialog->layout()) { + const QMargins m = layout->contentsMargins(); + layout->setContentsMargins(m.left() + kBorder, + m.top() + kTitleBarHeight + kBorder, + m.right() + kBorder, m.bottom() + kBorder); + } + new Chrome(dialog, closable, grip); +} + +} // namespace px::modrim diff --git a/src/ui/components/dialog_chrome.hpp b/src/ui/components/dialog_chrome.hpp new file mode 100644 index 0000000..0ea740b --- /dev/null +++ b/src/ui/components/dialog_chrome.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +class QDialog; + +namespace px::modrim { + +// Gives `dialog` the frameless title-bar look of the main window. Call after +// the dialog's layout is built and before it is shown. Native file pickers keep +// the OS look. The title bar is an overlay, so the dialog's own layout only +// needs extra top margin, which is added here. +void installDialogChrome(QDialog* dialog, bool closable = true, + std::optional resizable = std::nullopt); + +} // namespace px::modrim diff --git a/src/ui/components/dialogs.hpp b/src/ui/components/dialogs.hpp new file mode 100644 index 0000000..e424b5a --- /dev/null +++ b/src/ui/components/dialogs.hpp @@ -0,0 +1,68 @@ +#pragma once + +// Modal dialogs as coroutines: `co_await awaitDialog(args...)` shows the +// dialog and resumes with its result code and pointer. The dialog is scheduled +// for deletion, so read what you need from it before the next event-loop pass +// (the coroutine resumes synchronously from `finished`, so that holds for any +// code after the co_await). + +#include +#include +#include +#include +#include + +#include + +#include "ui/components/dialog_chrome.hpp" + +namespace px::modrim { + +// Resumes from inside `finished` itself. A queued resume could run after the +// dialog, and the window owning it, are gone; the coroutine must not outlive +// the emission. +class DialogFinished { + public: + explicit DialogFinished(QDialog* dialog) + : m_connection(QObject::connect( + dialog, &QDialog::finished, dialog, [this](int code) { + m_code = code; + m_finished = true; + if (m_waiter) std::exchange(m_waiter, nullptr).resume(); + })) {} + DialogFinished(const DialogFinished&) = delete; + DialogFinished& operator=(const DialogFinished&) = delete; + ~DialogFinished() { QObject::disconnect(m_connection); } + + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return m_finished; } + void await_suspend(std::coroutine_handle<> waiting) noexcept { + m_waiter = waiting; + } + int await_resume() const noexcept { return m_code; } + // NOLINTEND(readability-identifier-naming) + + private: + QMetaObject::Connection m_connection; + std::coroutine_handle<> m_waiter; + int m_code = QDialog::Rejected; + bool m_finished = false; +}; + +template T, class... Args> +QCoro::Task> awaitDialog(Args&&... args) { + auto* dialog = new T(std::forward(args)...); + installDialogChrome(dialog); + dialog->setModal(true); + + // Subscribe before show(), so a finish that happens immediately is not + // missed. + DialogFinished finished(dialog); + dialog->show(); + const int code = co_await finished; + + dialog->deleteLater(); + co_return std::pair{code, dialog}; +} + +} // namespace px::modrim diff --git a/src/ui/components/filter_sidebar.cpp b/src/ui/components/filter_sidebar.cpp new file mode 100644 index 0000000..0f6dd60 --- /dev/null +++ b/src/ui/components/filter_sidebar.cpp @@ -0,0 +1,27 @@ +#include "ui/components/filter_sidebar.hpp" + +#include +#include +#include + +#include "ui/theme/palette.hpp" +namespace px::modrim { +FilterSidebar::FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, + QWidget* parent) + : QQuickWidget(engine, parent) { + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_2")); + rootContext()->setContextProperty(QStringLiteral("filterSidebarModel"), + model); + setInitialProperties({{QStringLiteral("model"), QVariant::fromValue(model)}}); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/FilterSidebar.qml"))); + if (auto* root = rootObject()) + connect(root, SIGNAL(activated(int)), this, SIGNAL(activated(int))); +} +int FilterSidebar::currentIndex() const { + const auto* root = rootObject(); + const auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + return view ? view->property("currentIndex").toInt() : -1; +} +} // namespace px::modrim diff --git a/src/ui/components/filter_sidebar.hpp b/src/ui/components/filter_sidebar.hpp new file mode 100644 index 0000000..b381794 --- /dev/null +++ b/src/ui/components/filter_sidebar.hpp @@ -0,0 +1,14 @@ +#pragma once +#include +class QAbstractItemModel; +namespace px::modrim { +class FilterSidebar : public QQuickWidget { + Q_OBJECT + public: + FilterSidebar(QQmlEngine* engine, QAbstractItemModel* model, + QWidget* parent = nullptr); + int currentIndex() const; + signals: + void activated(int index); +}; +} // namespace px::modrim diff --git a/src/ui/components/header_controller.cpp b/src/ui/components/header_controller.cpp new file mode 100644 index 0000000..88e8fdc --- /dev/null +++ b/src/ui/components/header_controller.cpp @@ -0,0 +1,122 @@ +#include "ui/components/header_controller.hpp" + +#include +#include + +#include "core/constants.hpp" + +namespace px::modrim { + +namespace { + +QString strategyLabel(LaunchStrategy strategy) { + switch (strategy) { + case LaunchStrategy::kDirect: + return QStringLiteral("Direct executable"); + case LaunchStrategy::kSteam: + return QStringLiteral("Steam"); + } + return {}; +} + +} // namespace + +HeaderController::HeaderController(bool isFrameless, int initialStrategy, + QString appVersion, QVariantMap tooltips, + bool downloadsAvailable, QObject* parent) + : QObject(parent), + m_isFrameless(isFrameless), + m_appVersion(std::move(appVersion)), + m_tooltips(std::move(tooltips)), + m_downloadsAvailable(downloadsAvailable), + m_strategyIndex(initialStrategy) {} + +QVariantList HeaderController::strategies() const { + QVariantList list; + for (const auto strategy : + {LaunchStrategy::kDirect, LaunchStrategy::kSteam}) { + list.append( + QVariantMap{{QStringLiteral("index"), static_cast(strategy)}, + {QStringLiteral("label"), strategyLabel(strategy)}}); + } + return list; +} + +void HeaderController::setMaximized(bool value) { + if (m_maximized != value) { + m_maximized = value; + emit maximizedChanged(); + } +} + +void HeaderController::setUnsavedChanges(bool value) { + if (m_unsavedChanges != value) { + m_unsavedChanges = value; + emit unsavedChangesChanged(); + } +} + +void HeaderController::setLaunchState(const QString& state) { + if (m_launchState != state) { + m_launchState = state; + emit launchStateChanged(); + } +} + +void HeaderController::setTaskText(const QString& text) { + if (m_taskText != text) { + m_taskText = text; + emit taskTextChanged(); + } +} + +void HeaderController::setDownloadsBusy(bool busy) { + if (m_downloadsBusy != busy) { + m_downloadsBusy = busy; + emit downloadsBusyChanged(); + } +} + +void HeaderController::setDownloadsProgressShown(bool shown) { + if (m_downloadsProgressShown != shown) { + m_downloadsProgressShown = shown; + emit downloadsProgressShownChanged(); + } +} + +void HeaderController::setDownloadsProgress(const QString& text, int value, + int total) { + m_downloadsText = text; + m_downloadsValue = value; + m_downloadsTotal = total; + emit downloadsProgressChanged(); +} + +void HeaderController::refresh() { emit refreshRequested(); } + +void HeaderController::autoSort() { emit sortRequested(); } + +void HeaderController::openDownloads() { emit downloadsRequested(); } + +void HeaderController::updateMods() { emit updateModsRequested(); } + +void HeaderController::save() { emit saveRequested(); } + +void HeaderController::launch() { emit launchRequested(); } + +void HeaderController::minimize() { emit minimizeRequested(); } + +void HeaderController::maximize() { emit maximizeRequested(); } + +void HeaderController::closeWindow() { emit closeRequested(); } + +void HeaderController::setStrategy(int index) { + if (m_strategyIndex != index) { + m_strategyIndex = index; + emit strategyChanged(index); + } +} + +void HeaderController::dragStarted() { emit dragStartRequested(); } + +} // namespace px::modrim diff --git a/src/ui/components/header_controller.hpp b/src/ui/components/header_controller.hpp new file mode 100644 index 0000000..ad99b1a --- /dev/null +++ b/src/ui/components/header_controller.hpp @@ -0,0 +1,126 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::modrim { + +// State and commands behind Header.qml, exposed to it as `headerController`. +// The QML-facing names (properties and invokables) match the legacy +// HeaderController; the C++ side uses camelCase signals and setters. Owned by +// the caller. +class HeaderController : public QObject { + Q_OBJECT + + Q_PROPERTY(bool isFrameless READ isFrameless CONSTANT) + Q_PROPERTY(QString appVersion READ appVersion CONSTANT) + Q_PROPERTY(QVariantMap tooltips READ tooltips CONSTANT) + Q_PROPERTY(bool downloadsAvailable READ downloadsAvailable CONSTANT) + Q_PROPERTY(QVariantList strategies READ strategies CONSTANT) + + Q_PROPERTY( + bool maximized READ maximized WRITE setMaximized NOTIFY maximizedChanged) + Q_PROPERTY(int strategyIndex READ strategyIndex NOTIFY strategyChanged) + Q_PROPERTY(bool unsavedChanges READ unsavedChanges WRITE setUnsavedChanges + NOTIFY unsavedChangesChanged) + Q_PROPERTY(QString launchState READ launchState WRITE setLaunchState NOTIFY + launchStateChanged) + Q_PROPERTY(QString taskText READ taskText NOTIFY taskTextChanged) + Q_PROPERTY(bool downloadsBusy READ downloadsBusy WRITE setDownloadsBusy NOTIFY + downloadsBusyChanged) + Q_PROPERTY(bool downloadsProgressShown READ downloadsProgressShown NOTIFY + downloadsProgressShownChanged) + Q_PROPERTY( + QString downloadsText READ downloadsText NOTIFY downloadsProgressChanged) + Q_PROPERTY( + int downloadsValue READ downloadsValue NOTIFY downloadsProgressChanged) + Q_PROPERTY( + int downloadsTotal READ downloadsTotal NOTIFY downloadsProgressChanged) + + public: + HeaderController(bool isFrameless, int initialStrategy, QString appVersion, + QVariantMap tooltips, bool downloadsAvailable, + QObject* parent = nullptr); + + bool isFrameless() const { return m_isFrameless; } + const QString& appVersion() const { return m_appVersion; } + const QVariantMap& tooltips() const { return m_tooltips; } + bool downloadsAvailable() const { return m_downloadsAvailable; } + // [{index, label}] for each launch strategy, in enum order. + QVariantList strategies() const; + + bool maximized() const { return m_maximized; } + int strategyIndex() const { return m_strategyIndex; } + bool unsavedChanges() const { return m_unsavedChanges; } + const QString& launchState() const { return m_launchState; } + const QString& taskText() const { return m_taskText; } + bool downloadsBusy() const { return m_downloadsBusy; } + bool downloadsProgressShown() const { return m_downloadsProgressShown; } + const QString& downloadsText() const { return m_downloadsText; } + int downloadsValue() const { return m_downloadsValue; } + int downloadsTotal() const { return m_downloadsTotal; } + + void setMaximized(bool value); + void setUnsavedChanges(bool value); + void setLaunchState(const QString& state); + void setTaskText(const QString& text); + void setDownloadsBusy(bool busy); + void setDownloadsProgressShown(bool shown); + void setDownloadsProgress(const QString& text, int value, int total); + + Q_INVOKABLE void refresh(); + Q_INVOKABLE void autoSort(); + Q_INVOKABLE void openDownloads(); + Q_INVOKABLE void updateMods(); + Q_INVOKABLE void save(); + Q_INVOKABLE void launch(); + Q_INVOKABLE void minimize(); + Q_INVOKABLE void maximize(); + Q_INVOKABLE void closeWindow(); + Q_INVOKABLE void setStrategy(int index); + // Called by QML when a drag starts on the header background. Emits + // dragStartRequested. + Q_INVOKABLE void dragStarted(); + + signals: + void refreshRequested(); + void sortRequested(); + void saveRequested(); + void launchRequested(); + void strategyChanged(int index); + void minimizeRequested(); + void maximizeRequested(); + void closeRequested(); + void dragStartRequested(); + void updateModsRequested(); + void downloadsRequested(); + + void maximizedChanged(); + void unsavedChangesChanged(); + void launchStateChanged(); + void taskTextChanged(); + void downloadsBusyChanged(); + void downloadsProgressShownChanged(); + void downloadsProgressChanged(); + + private: + bool m_isFrameless; + QString m_appVersion; + QVariantMap m_tooltips; + bool m_downloadsAvailable; + + bool m_maximized = false; + int m_strategyIndex; + bool m_unsavedChanges = false; + QString m_launchState = QStringLiteral("idle"); + QString m_taskText; + bool m_downloadsBusy = false; + bool m_downloadsProgressShown = true; + QString m_downloadsText; + int m_downloadsValue = 0; + int m_downloadsTotal = 0; +}; + +} // namespace px::modrim diff --git a/src/ui/components/header_panel.cpp b/src/ui/components/header_panel.cpp new file mode 100644 index 0000000..1bf17e5 --- /dev/null +++ b/src/ui/components/header_panel.cpp @@ -0,0 +1,27 @@ +#include "ui/components/header_panel.hpp" + +#include +#include +#include + +#include + +#include "ui/components/header_controller.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +HeaderPanel::HeaderPanel(HeaderController& controller, QQmlEngine* engine, + QWidget* parent) + : QQuickWidget(engine, parent) { + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_1")); + rootContext()->setContextProperty(QStringLiteral("headerController"), + &controller); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Header.qml"))); + if (rootObject() == nullptr) { + spdlog::error("Header.qml failed to load: rootObject is null"); + } +} + +} // namespace px::modrim diff --git a/src/ui/components/header_panel.hpp b/src/ui/components/header_panel.hpp new file mode 100644 index 0000000..ae3a7b9 --- /dev/null +++ b/src/ui/components/header_panel.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +class QQmlEngine; +class QWidget; + +namespace px::modrim { + +class HeaderController; + +// Hosts Header.qml, the main window's top bar. The controller is injected and +// exposed to QML as `headerController`; the caller owns it and must outlive +// this panel. +class HeaderPanel : public QQuickWidget { + public: + HeaderPanel(HeaderController& controller, QQmlEngine* engine, + QWidget* parent = nullptr); +}; + +} // namespace px::modrim diff --git a/src/ui/components/icon_button.cpp b/src/ui/components/icon_button.cpp new file mode 100644 index 0000000..cb78ec2 --- /dev/null +++ b/src/ui/components/icon_button.cpp @@ -0,0 +1,49 @@ +#include "ui/components/icon_button.hpp" + +#include +#include + +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +// Every icon name used here is in the icon table; a miss is a programming +// error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +} // namespace + +IconButton::IconButton(const QString& iconName, const QString& tooltip, + bool primary, int size, QWidget* parent) + : QPushButton(parent), + m_iconName(iconName), + m_iconPx(std::max(16, size / 2)) { + setIconColor(paletteValue(u"TEXT_MUTED")); + setFixedSize(size, size); + setObjectName(QStringLiteral("iconBtn")); + // QSS min-height on QPushButton overrides setFixedSize; the 1px border sits + // outside the QSS content box, so subtract it. + const int inner = size - 2; + setStyleSheet( + QStringLiteral("QPushButton#iconBtn { min-width: %1px; max-width: %1px;" + " min-height: %1px; max-height: %1px; }") + .arg(inner)); + if (primary) { + setProperty("primary", true); + } + setCursor(Qt::PointingHandCursor); + setToolTip(tooltip); +} + +void IconButton::setIconColor(const QString& color) { + setIcon(requireIcon(m_iconName, m_iconPx, color)); +} + +} // namespace px::modrim diff --git a/src/ui/components/icon_button.hpp b/src/ui/components/icon_button.hpp new file mode 100644 index 0000000..5a69a01 --- /dev/null +++ b/src/ui/components/icon_button.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +class QWidget; + +namespace px::modrim { + +// Square icon-only button (QSS `iconBtn`). `primary` paints it in the accent +// color. +class IconButton : public QPushButton { + public: + IconButton(const QString& iconName, const QString& tooltip = {}, + bool primary = false, int size = 32, QWidget* parent = nullptr); + + void setIconColor(const QString& color); + + private: + QString m_iconName; + int m_iconPx; +}; + +} // namespace px::modrim diff --git a/src/ui/components/icon_table.inc b/src/ui/components/icon_table.inc new file mode 100644 index 0000000..a6387cc --- /dev/null +++ b/src/ui/components/icon_table.inc @@ -0,0 +1,43 @@ +// Generated once from legacy/pxmodrim/ui/components/icons.py (_ICONS, same order). +// Markup keeps `currentColor`; iconSvg() substitutes the requested color. +{QLatin1StringView{"thumbs-up"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"logo"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"download"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"refresh"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"sort"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"save"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"settings"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"search"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"check"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"warning"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"error"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"close"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"minimize"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"maximize"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"restore"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"chevron"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"chevron-left"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"folder"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"tag"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"steam"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"local"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"git"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"grid"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"home"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"mods"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"check-circle"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"ban"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"help"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"info"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"play"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"empty"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"link"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"copy"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"chevron-down"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"clock"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"trash"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"donut"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"bars"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"grip"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"star"}, QLatin1StringView{R"SVG()SVG"}}, +{QLatin1StringView{"star-filled"}, QLatin1StringView{R"SVG()SVG"}}, diff --git a/src/ui/components/icons.cpp b/src/ui/components/icons.cpp new file mode 100644 index 0000000..3263c13 --- /dev/null +++ b/src/ui/components/icons.cpp @@ -0,0 +1,88 @@ +#include "ui/components/icons.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace px::modrim { + +namespace { + +struct IconEntry { + QLatin1StringView name; + QLatin1StringView svg; +}; + +constexpr IconEntry kIcons[]{ +#include "ui/components/icon_table.inc" +}; + +constexpr QLatin1StringView kSvgNamespace{ + "xmlns=\"http://www.w3.org/2000/svg\""}; + +const IconEntry* findIcon(QStringView name) { + for (const auto& entry : kIcons) { + if (QString(entry.name) == name) { + return &entry; + } + } + return nullptr; +} + +} // namespace + +std::optional iconSvg(QStringView name, QStringView color) { + const IconEntry* entry = findIcon(name); + if (!entry) { + return std::nullopt; + } + QString svg(entry->svg); + if (!svg.contains(QString(kSvgNamespace))) { + svg.replace( + QStringLiteral(" iconPixmap(QStringView name, int size, + QStringView color) { + const auto svg = iconSvg(name, color); + if (!svg) { + return std::nullopt; + } + + const QScreen* screen = QGuiApplication::primaryScreen(); + const qreal scale = + std::max(2.0, screen ? screen->devicePixelRatio() : 1.0); + const int pixels = qRound(size * scale); + + QPixmap pixmap(pixels, pixels); + pixmap.setDevicePixelRatio(scale); + pixmap.fill(Qt::transparent); + + QPainter painter(&pixmap); + painter.setRenderHints(QPainter::Antialiasing | + QPainter::SmoothPixmapTransform); + QSvgRenderer renderer(svg->toUtf8()); + if (renderer.isValid()) { + renderer.render(&painter, QRectF(0, 0, size, size)); + } + painter.end(); + return pixmap; +} + +std::optional icon(QStringView name, int size, QStringView color) { + auto pixmap = iconPixmap(name, size, color); + if (!pixmap) { + return std::nullopt; + } + return QIcon(*pixmap); +} + +} // namespace px::modrim diff --git a/src/ui/components/icons.hpp b/src/ui/components/icons.hpp new file mode 100644 index 0000000..ae284d3 --- /dev/null +++ b/src/ui/components/icons.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::modrim { + +// The SVG markup of a named icon with `currentColor` replaced by `color`, or +// nullopt when no icon has that name. +std::optional iconSvg(QStringView name, QStringView color); + +// Rasterised at >= 2x so small icons stay crisp on 1x screens; the pixmap's +// device pixel ratio carries the scale. nullopt for an unknown name. +std::optional iconPixmap(QStringView name, int size, + QStringView color); + +std::optional icon(QStringView name, int size, QStringView color); + +} // namespace px::modrim diff --git a/src/ui/components/mod_activation.cpp b/src/ui/components/mod_activation.cpp new file mode 100644 index 0000000..4cc2155 --- /dev/null +++ b/src/ui/components/mod_activation.cpp @@ -0,0 +1,60 @@ +#include "ui/components/mod_activation.hpp" + +#include + +#include "core/context.hpp" +#include "core/services/activation_service.hpp" +#include "ui/components/dialogs.hpp" +namespace px::modrim { +DependentModsDialog::DependentModsDialog(const QStringList& dependents, + QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Warning); + setWindowTitle(QStringLiteral("Disable Dependent Mods")); + QStringList details; + for (const auto& dependent : dependents) + details.append(QStringLiteral("• %1").arg(dependent)); + setText(QStringLiteral( + "These active mods depend on the mod you are disabling:\n\n") + + details.join('\n')); + setInformativeText( + QStringLiteral("Choose whether to disable only the selected mod or these " + "dependents too.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel); + setDefaultButton(QMessageBox::No); + button(QMessageBox::Yes) + ->setText(QStringLiteral("Disable selected + dependents")); + button(QMessageBox::No)->setText(QStringLiteral("Disable selected only")); + button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); +} +QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, + const QStringList& enable, + const QStringList& disable) { + // Copy before suspension: callers often pass temporary batches. + const QStringList enabling = enable; + QStringList disabling = disable; + const auto dependents = ctx.activation().dependentsOf(disabling, enabling); + if (!dependents.isEmpty()) { + QStringList details; + for (const auto& uuid : dependents) { + const auto& mod = ctx.allMods().value(uuid); + const auto* about = dynamic_cast(mod.get()); + details.append(about ? QStringLiteral("%1 (%2)").arg( + mod->name, about->packageId.str()) + : mod->name); + } + const auto [result, dialog] = + co_await awaitDialog(details, parent); + if (result == QMessageBox::Cancel) co_return false; + if (result == QMessageBox::Yes) disabling.append(dependents); + } + co_return ctx.activation().apply(enabling, disabling); +} +QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, + const QStringList& uuids) { + QStringList enable, disable; + for (const auto& uuid : uuids) + (ctx.activeUuids().contains(uuid) ? disable : enable).append(uuid); + co_return co_await applyActivation(ctx, parent, enable, disable); +} +} // namespace px::modrim diff --git a/src/ui/components/mod_activation.hpp b/src/ui/components/mod_activation.hpp new file mode 100644 index 0000000..8afb5af --- /dev/null +++ b/src/ui/components/mod_activation.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +#include +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +class DependentModsDialog : public QMessageBox { + public: + DependentModsDialog(const QStringList& dependents, QWidget* parent); +}; +QCoro::Task applyActivation(CoreContext& ctx, QWidget* parent, + const QStringList& enable = {}, + const QStringList& disable = {}); +QCoro::Task toggleMods(CoreContext& ctx, QWidget* parent, + const QStringList& uuids); +} // namespace px::modrim diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp new file mode 100644 index 0000000..b12cc36 --- /dev/null +++ b/src/ui/components/mod_updates.cpp @@ -0,0 +1,78 @@ +#include "ui/components/mod_updates.hpp" + +#include +#include +#include +#include + +#include "core/context.hpp" +#include "core/downloads/manager.hpp" +#include "ui/components/dialogs.hpp" +namespace px::modrim { +QStringList updatableIds(CoreContext& ctx, const QStringList& uuids) { + QList mods; + mods.reserve(uuids.size()); + for (const auto& uuid : uuids) { + const auto it = ctx.allMods().constFind(uuid); + if (it != ctx.allMods().cend()) mods.append(it.value()); + } + return ctx.downloads().updatableIds(mods); +} +UpdateState updateState(CoreContext& ctx, const QStringList& uuids) { + const auto ids = updatableIds(ctx, uuids); + QString label = QStringLiteral("Update"); + if (uuids.size() > 1) + label += QStringLiteral(" (%1 of %2)").arg(ids.size()).arg(uuids.size()); + const auto& downloads = ctx.downloads(); + if (!downloads.available()) + return {label, false, QStringLiteral("No download sources are enabled")}; + if (downloads.isDownloading()) + return {label, false, QStringLiteral("A download is in progress")}; + if (ids.isEmpty()) + return {label, false, + QStringLiteral("None of these mods were downloaded by PxModRim")}; + return {label, true, {}}; +} +QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, + const QStringList& uuids, + std::function handler) { + if (!ctx.downloads().available()) return nullptr; + const auto state = updateState(ctx, uuids); + auto* action = menu->addAction(state.label); + QObject::connect(action, &QAction::triggered, menu, + [handler = std::move(handler)] { handler(); }); + action->setEnabled(state.enabled); + action->setToolTip(state.tooltip); + return action; +} +ConfirmUpdateDialog::ConfirmUpdateDialog(int count, QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Question); + setWindowTitle(QStringLiteral("Update Mods")); + setText(QStringLiteral("Update %1 mods?").arg(count)); + setInformativeText( + QStringLiteral("Every mod downloaded by PxModRim is checked against its " + "source and changed files are downloaded. Mods managed by " + "other tools, such as Steam's own Workshop folder, are " + "not included. This can take a long time.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + button(QMessageBox::Yes)->setText(QStringLiteral("Update")); + button(QMessageBox::Yes)->setObjectName(QStringLiteral("primaryAction")); +} +QCoro::Task confirmUpdate(int count, QWidget* parent) { + if (count < kConfirmThreshold) co_return true; + const auto [result, dialog] = + co_await awaitDialog(count, parent); + co_return result == QMessageBox::Yes; +} +QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, + QWidget* parent) { + const auto ids = updatableIds(ctx, uuids); + if (ids.isEmpty() || + !(co_await confirmUpdate(static_cast(ids.size()), parent))) + co_return; + const auto result = co_await ctx.downloads().downloadMods(ids); + if (!result) qWarning() << "Update not started:" << result.error().message; +} +} // namespace px::modrim diff --git a/src/ui/components/mod_updates.hpp b/src/ui/components/mod_updates.hpp new file mode 100644 index 0000000..2941a08 --- /dev/null +++ b/src/ui/components/mod_updates.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include + +#include +class QMenu; +class QAction; +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +inline constexpr int kConfirmThreshold = 25; +struct UpdateState { + QString label; + bool enabled; + QString tooltip; +}; +QStringList updatableIds(CoreContext& ctx, const QStringList& uuids); +UpdateState updateState(CoreContext& ctx, const QStringList& uuids); +QAction* addUpdateAction(QMenu* menu, CoreContext& ctx, + const QStringList& uuids, + std::function handler); +class ConfirmUpdateDialog : public QMessageBox { + public: + ConfirmUpdateDialog(int count, QWidget* parent); +}; +QCoro::Task confirmUpdate(int count, QWidget* parent); +QCoro::Task updateMods(CoreContext& ctx, const QStringList& uuids, + QWidget* parent); +} // namespace px::modrim diff --git a/src/ui/components/popup_parent.cpp b/src/ui/components/popup_parent.cpp new file mode 100644 index 0000000..7658ea3 --- /dev/null +++ b/src/ui/components/popup_parent.cpp @@ -0,0 +1,24 @@ +#include "ui/components/popup_parent.hpp" + +#include +#include +#include +#include +#include + +namespace px::modrim { + +QuickPopupParenting::QuickPopupParenting(QObject* parent) : QObject(parent) {} + +bool QuickPopupParenting::eventFilter(QObject* watched, QEvent* event) { + if (event->type() == QEvent::Show) { + if (auto* quick = qobject_cast(watched)) { + if (QWindow* top = quick->window()->windowHandle()) { + quick->quickWindow()->setTransientParent(top); + } + } + } + return false; +} + +} // namespace px::modrim diff --git a/src/ui/components/popup_parent.hpp b/src/ui/components/popup_parent.hpp new file mode 100644 index 0000000..ad13f9f --- /dev/null +++ b/src/ui/components/popup_parent.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +class QEvent; + +namespace px::modrim { + +// Gives every QQuickWidget's offscreen window its top-level window as transient +// parent when shown. Without it Wayland refuses Popup.Window popups (menus, +// combo box lists, tooltips): "Failed to create popup … transientParent". +// Install on the QApplication, parented to it. +class QuickPopupParenting : public QObject { + Q_OBJECT + + public: + explicit QuickPopupParenting(QObject* parent); + + protected: + bool eventFilter(QObject* watched, QEvent* event) override; +}; + +} // namespace px::modrim diff --git a/src/ui/components/procedural_preview.cpp b/src/ui/components/procedural_preview.cpp new file mode 100644 index 0000000..947d7a5 --- /dev/null +++ b/src/ui/components/procedural_preview.cpp @@ -0,0 +1,119 @@ +#include "ui/components/procedural_preview.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/theme/constants.hpp" + +namespace px::modrim { +QString initials(const QString& title) { + static const QRegularExpression tags(R"([\[(][^\])]*[\])])"); + static const QRegularExpression words( + R"([^\W_]+)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression camel( + R"([A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+|[^\W\d_a-zA-Z]+)", + QRegularExpression::UseUnicodePropertiesOption); + static const QSet stop{"a", "an", "and", "for", "of", + "the", "to", "in", "on"}; + QString stripped = title; + stripped.replace(tags, " "); + const bool useStripped = words.match(stripped).hasMatch(); + QStringList all, meaningful; + auto chunks = words.globalMatch(useStripped ? stripped : title); + while (chunks.hasNext()) { + const QString chunk = chunks.next().captured(); + auto parts = camel.globalMatch(chunk); + if (!parts.hasNext()) all.append(chunk); + while (parts.hasNext()) all.append(parts.next().captured()); + } + for (const auto& word : all) + if (!stop.contains(word.toLower())) meaningful.append(word); + if (meaningful.isEmpty()) meaningful = all; + if (meaningful.isEmpty()) + return title.trimmed().isEmpty() ? QStringLiteral("?") + : title.trimmed().left(2).toUpper(); + if (meaningful.size() == 1) { + auto tagMatches = tags.globalMatch(title); + QString tagText; + while (tagMatches.hasNext()) tagText += tagMatches.next().captured() + " "; + const auto tagWord = words.match(tagText); + if (useStripped && tagWord.hasMatch()) + return (tagWord.captured().left(1) + meaningful.front().left(1)) + .toUpper(); + return meaningful.front().left(2).toUpper(); + } + return (meaningful[0].left(1) + meaningful[1].left(1)).toUpper(); +} +namespace { +double seedrand(const QString& seed) { + const auto hash = + QCryptographicHash::hash(seed.toUtf8(), QCryptographicHash::Sha256); + unsigned remainder = 0; + for (char byte : hash) + remainder = (remainder * 256 + static_cast(byte)) % 10000; + return remainder / 10000.0; +} +QColor hsl(double hue, double saturation, double lightness) { + return QColor::fromHslF(hue - std::floor(hue), + std::clamp(saturation, 0.0, 1.0), + std::clamp(lightness, 0.0, 1.0)); +} +void blob(QPainter& painter, double x, double y, double radius, + const QColor& color) { + QRadialGradient gradient(QPointF(x, y), radius); + QColor outer = color; + outer.setAlpha(0); + gradient.setColorAt(0, color); + gradient.setColorAt(1, outer); + painter.setBrush(gradient); + painter.drawEllipse(QPointF(x, y), radius, radius); +} +} // namespace +QImage generatePreview(const QString& title, int width, int height) { + static QMutex mutex; + static QCache cache(16); + const QString key = + QString::number(width) + ":" + QString::number(height) + ":" + title; + { + QMutexLocker lock(&mutex); + if (auto* cached = cache.object(key)) return *cached; + } + QImage image(width, height, QImage::Format_ARGB32_Premultiplied); + const double hue = seedrand(title); + image.fill(hsl(hue, .40, .12)); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(Qt::NoPen); + const int span = std::max(width, height); + const double jx = seedrand(title + "jx") * .2, + jy = seedrand(title + "jy") * .2; + QColor warm = hsl(hue, .7, .5), cool = hsl(hue + .14, .7, .5); + warm.setAlphaF(.75); + cool.setAlphaF(.65); + blob(painter, width * (.15 + jx), height * (.1 + jy), span * .6, warm); + blob(painter, width * (.85 - jx), height * (.9 - jy), span * .55, cool); + QFont font = painter.font(); + font.setBold(true); + font.setWeight(QFont::ExtraBold); + font.setPixelSize(std::max(32, static_cast(height * .36))); + font.setLetterSpacing(QFont::PercentageSpacing, 96); + painter.setFont(font); + painter.setPen(QColor(255, 255, 255, 210)); + const int visible = std::max(height - kBannerOverlayHeight, height / 2); + painter.drawText(QRectF(0, 0, width, visible), Qt::AlignCenter, + initials(title)); + painter.end(); + QMutexLocker lock(&mutex); + cache.insert(key, new QImage(image)); + return image; +} +} // namespace px::modrim diff --git a/src/ui/components/procedural_preview.hpp b/src/ui/components/procedural_preview.hpp new file mode 100644 index 0000000..7cb862e --- /dev/null +++ b/src/ui/components/procedural_preview.hpp @@ -0,0 +1,7 @@ +#pragma once +#include +#include +namespace px::modrim { +QString initials(const QString& title); +QImage generatePreview(const QString& title, int width, int height); +} // namespace px::modrim diff --git a/src/ui/components/progress_dialog.cpp b/src/ui/components/progress_dialog.cpp new file mode 100644 index 0000000..e633745 --- /dev/null +++ b/src/ui/components/progress_dialog.cpp @@ -0,0 +1,152 @@ +#include "ui/components/progress_dialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/dialog_chrome.hpp" +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +constexpr int kHideDelayMs = 300; +constexpr int kFadeInMs = 150; +constexpr int kFadeOutMs = 200; + +// Every icon name used here is in the icon table; a miss is a programming +// error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +} // namespace + +ProgressDialog::ProgressDialog(LoadingState& loading, QWidget* parent) + : QDialog(parent, Qt::WindowStaysOnTopHint | Qt::CustomizeWindowHint | + Qt::WindowTitleHint), + m_loading(loading) { + setWindowTitle(QStringLiteral("Loading")); + setMinimumWidth(420); + setModal(true); + + m_crumbs = new QWidget(this); + m_crumbsLayout = new QHBoxLayout(m_crumbs); + m_crumbsLayout->setContentsMargins(0, 0, 0, 8); + m_crumbsLayout->setSpacing(4); + + m_progress = new QProgressBar(this); + m_progress->setTextVisible(false); + m_progress->setRange(0, 100); + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(12, 12, 12, 12); + layout->setSpacing(0); + layout->addWidget(m_crumbs); + layout->addWidget(m_progress); + installDialogChrome(this, false); + + m_hideTimer = new QTimer(this); + m_hideTimer->setSingleShot(true); + m_hideTimer->setInterval(kHideDelayMs); + connect(m_hideTimer, &QTimer::timeout, this, &ProgressDialog::beginHide); + + m_fade = + new QPropertyAnimation(this, QByteArrayLiteral("windowOpacity"), this); + connect(m_fade, &QPropertyAnimation::finished, this, + &ProgressDialog::onFadeFinished); + + m_connections.push_back(QObject::connect( + &m_loading, &LoadingState::changed, this, + [this](const QString&, int pct, int, int) { onChanged(pct); })); + m_connections.push_back(QObject::connect(&m_loading, &LoadingState::finished, + this, &ProgressDialog::onFinished)); +} + +ProgressDialog::~ProgressDialog() { + for (const auto& connection : m_connections) { + QObject::disconnect(connection); + } +} + +void ProgressDialog::onChanged(int pct) { + m_hideTimer->stop(); + if (!m_shown) { + m_shown = true; + if (!isVisible()) { + setWindowOpacity(0.0); + show(); + } + fadeTo(1.0, kFadeInMs); + } + + const QStringList statuses = m_loading.statuses(); + if (!statuses.isEmpty()) { + setWindowTitle(statuses.first()); + } + updateBreadcrumbs(statuses); + m_progress->setValue(pct); +} + +void ProgressDialog::onFinished() { m_hideTimer->start(); } + +void ProgressDialog::updateBreadcrumbs(const QStringList& statuses) { + while (QLayoutItem* item = m_crumbsLayout->takeAt(0)) { + if (QWidget* widget = item->widget()) { + widget->hide(); + widget->deleteLater(); + } + delete item; + } + + const qsizetype last = statuses.size() - 1; + for (qsizetype i = 0; i < statuses.size(); ++i) { + auto* label = new QLabel(statuses[i], m_crumbs); + if (i == last) { + QFont font = label->font(); + font.setBold(true); + label->setFont(font); + } + m_crumbsLayout->addWidget(label); + + if (i < last) { + auto* separator = new QLabel(m_crumbs); + separator->setPixmap( + requireIcon(u"chevron", 12, paletteValue(u"TEXT_DIM")) + .pixmap(12, 12)); + m_crumbsLayout->addWidget(separator); + } + } + m_crumbsLayout->addStretch(); +} + +void ProgressDialog::beginHide() { + m_shown = false; + fadeTo(0.0, kFadeOutMs); +} + +void ProgressDialog::fadeTo(qreal end, int durationMs) { + m_fade->stop(); + m_fade->setDuration(durationMs); + m_fade->setStartValue(windowOpacity()); + m_fade->setEndValue(end); + m_fade->start(); +} + +void ProgressDialog::onFadeFinished() { + if (!m_shown) { + hide(); + } +} + +} // namespace px::modrim diff --git a/src/ui/components/progress_dialog.hpp b/src/ui/components/progress_dialog.hpp new file mode 100644 index 0000000..f0a62a0 --- /dev/null +++ b/src/ui/components/progress_dialog.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/loading.hpp" + +class QHBoxLayout; +class QProgressBar; +class QPropertyAnimation; +class QTimer; +class QWidget; + +namespace px::modrim { + +// Modal progress dialog showing the stacked loading tasks as breadcrumbs. Shows +// when the first task reports, and fades out shortly after the last one +// finishes unless a new task starts. +class ProgressDialog : public QDialog { + Q_OBJECT + + public: + ProgressDialog(LoadingState& loading, QWidget* parent); + ~ProgressDialog() override; + + LoadingState& loading() const noexcept { return m_loading; } + + private: + void onChanged(int pct); + void onFinished(); + void updateBreadcrumbs(const QStringList& statuses); + void beginHide(); + void fadeTo(qreal end, int durationMs); + void onFadeFinished(); + + LoadingState& m_loading; + bool m_shown = false; + + QWidget* m_crumbs = nullptr; + QHBoxLayout* m_crumbsLayout = nullptr; + QProgressBar* m_progress = nullptr; + QTimer* m_hideTimer = nullptr; + QPropertyAnimation* m_fade = nullptr; + + std::vector m_connections; +}; + +} // namespace px::modrim diff --git a/src/ui/components/svg_provider.cpp b/src/ui/components/svg_provider.cpp new file mode 100644 index 0000000..12c52d8 --- /dev/null +++ b/src/ui/components/svg_provider.cpp @@ -0,0 +1,71 @@ +#include "ui/components/svg_provider.hpp" + +#include +#include +#include +#include +#include + +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +constexpr QLatin1StringView kEmptySvg{ + R"()"}; + +// The color parameter, percent-decoded. QML URL-encodes "#" and some paths +// deliver the digits without it, so a leading "23" is taken as an encoded "#". +QString colorFromQuery(const QString& query) { + QString color = paletteValue(u"TEXT_MUTED"); + for (const QString& param : query.split(QLatin1Char('&'))) { + if (!param.startsWith(QLatin1StringView("color="))) { + continue; + } + color = QUrl::fromPercentEncoding(param.mid(6).toUtf8()); + if (!color.startsWith(QLatin1Char('#')) && + color.startsWith(QLatin1StringView("23"))) { + color = QStringLiteral("#") + color.mid(2); + } + } + return color; +} + +} // namespace + +SvgIconProvider::SvgIconProvider() + : QQuickImageProvider(QQuickImageProvider::Pixmap) {} + +QPixmap SvgIconProvider::requestPixmap(const QString& id, QSize*, + const QSize& requestedSize) { + const qsizetype questionMark = id.indexOf(QLatin1Char('?')); + const QString name = questionMark < 0 ? id : id.left(questionMark); + const QString query = questionMark < 0 ? QString{} : id.mid(questionMark + 1); + + const QString svg = iconSvg(name, colorFromQuery(query)) + .value_or(QString::fromLatin1(kEmptySvg)); + + const QSize size = requestedSize.isValid() ? requestedSize : QSize(16, 16); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + QSvgRenderer renderer(svg.toUtf8()); + renderer.render(&painter); + painter.end(); + return pixmap; +} + +QQmlEngine* createQmlEngine(QObject* parent) { + if (QQuickStyle::name().isEmpty()) { + QQuickStyle::setStyle(QStringLiteral("Basic")); + } + auto* engine = new QQmlEngine(parent); + // The engine takes ownership of image providers (it holds them in a shared + // pointer), so the provider is created without a parent. + engine->addImageProvider(QStringLiteral("icons"), new SvgIconProvider()); + return engine; +} + +} // namespace px::modrim diff --git a/src/ui/components/svg_provider.hpp b/src/ui/components/svg_provider.hpp new file mode 100644 index 0000000..fe13e8a --- /dev/null +++ b/src/ui/components/svg_provider.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include + +class QObject; +class QQmlEngine; + +namespace px::modrim { + +// Serves named SVG icons to QML via image://icons/?color=. +// An unknown name renders an empty SVG, so the image is transparent. +class SvgIconProvider : public QQuickImageProvider { + public: + SvgIconProvider(); + + QPixmap requestPixmap(const QString& id, QSize* size, + const QSize& requestedSize) override; +}; + +// The one way to build a QML engine: every QML surface shares it, and it always +// carries the `icons` provider, so image://icons/… URLs resolve. +QQmlEngine* createQmlEngine(QObject* parent); + +} // namespace px::modrim diff --git a/src/ui/components/toast.cpp b/src/ui/components/toast.cpp new file mode 100644 index 0000000..359890f --- /dev/null +++ b/src/ui/components/toast.cpp @@ -0,0 +1,161 @@ +#include "ui/components/toast.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/components/icons.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +struct LevelStyle { + QStringView icon; + QStringView accent; +}; + +// Indexed by ToastLevel. +constexpr LevelStyle kLevelStyles[]{ + {u"info", u"PRIMARY"}, + {u"check-circle", u"SUCCESS"}, + {u"warning", u"WARNING"}, + {u"error", u"DANGER"}, +}; + +// Every icon name used here is in the icon table; a miss is a programming +// error. +QIcon requireIcon(QStringView name, int size, QStringView color) { + auto found = icon(name, size, color); + Q_ASSERT(found.has_value()); + return found.value_or(QIcon()); +} + +} // namespace + +Toast::Toast(const QString& message, ToastLevel level, QWidget* parent) + : QWidget(parent) { + setObjectName(QStringLiteral("toast")); + setFixedWidth(320); + setAttribute(Qt::WA_StyledBackground, true); + + const LevelStyle& style = kLevelStyles[static_cast(level)]; + + m_opacityEffect = new QGraphicsOpacityEffect(this); + m_opacityEffect->setOpacity(0.0); + setGraphicsEffect(m_opacityEffect); + + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(12, 10, 8, 10); + layout->setSpacing(8); + + auto* iconLabel = new QLabel(this); + iconLabel->setPixmap( + requireIcon(style.icon, 16, paletteValue(style.accent)).pixmap(16, 16)); + iconLabel->setFixedSize(16, 16); + layout->addWidget(iconLabel); + + auto* messageLabel = new QLabel(message, this); + messageLabel->setObjectName(QStringLiteral("toastMessage")); + messageLabel->setWordWrap(true); + layout->addWidget(messageLabel, 1); + + auto* close = new QPushButton(this); + close->setObjectName(QStringLiteral("toastClose")); + close->setIcon(requireIcon(u"close", 10, paletteValue(u"TEXT_DIM"))); + close->setFixedSize(20, 20); + close->setToolTip(QStringLiteral("Dismiss")); + connect(close, &QPushButton::clicked, this, &Toast::dismiss); + layout->addWidget(close); + + m_opacityAnim = new QPropertyAnimation(m_opacityEffect, + QByteArrayLiteral("opacity"), this); + m_opacityAnim->setDuration(200); + m_opacityAnim->setStartValue(0.0); + m_opacityAnim->setEndValue(1.0); + + m_fadeOutAnim = new QPropertyAnimation(m_opacityEffect, + QByteArrayLiteral("opacity"), this); + m_fadeOutAnim->setDuration(150); + m_fadeOutAnim->setEndValue(0.0); + connect(m_fadeOutAnim, &QPropertyAnimation::finished, this, + &Toast::dismissed); +} + +void Toast::showWithAnimation() { + show(); + raise(); + m_opacityAnim->setDirection(QAbstractAnimation::Direction::Forward); + m_opacityAnim->start(); +} + +void Toast::dismiss() { + if (m_dismissing) { + return; + } + m_dismissing = true; + m_opacityAnim->stop(); + m_fadeOutAnim->setStartValue(m_opacityEffect->opacity()); + m_fadeOutAnim->start(); +} + +ToastManager::ToastManager(QWidget* parent) : QWidget(parent) { + setObjectName(QStringLiteral("toastManager")); + setAttribute(Qt::WA_TransparentForMouseEvents, true); + + m_layout = new QVBoxLayout(this); + m_layout->setContentsMargins(0, 0, 16, 16); + m_layout->setSpacing(8); + m_layout->setAlignment(Qt::AlignBottom | Qt::AlignRight); +} + +void ToastManager::showToast(const QString& message, ToastLevel level, + int durationMs) { + auto* toast = new Toast(message, level, this); + m_layout->addWidget(toast); + connect(toast, &Toast::dismissed, this, [this, toast] { remove(toast); }); + + toast->showWithAnimation(); + + if (durationMs > 0) { + QTimer::singleShot(durationMs, toast, &Toast::dismiss); + } +} + +void ToastManager::info(const QString& message, int durationMs) { + showToast(message, ToastLevel::kInfo, durationMs); +} + +void ToastManager::success(const QString& message, int durationMs) { + showToast(message, ToastLevel::kSuccess, durationMs); +} + +void ToastManager::warning(const QString& message, int durationMs) { + showToast(message, ToastLevel::kWarning, durationMs); +} + +void ToastManager::error(const QString& message, int durationMs) { + showToast(message, ToastLevel::kError, durationMs); +} + +void ToastManager::resizeToParent() { + if (const QWidget* parent = parentWidget()) { + setGeometry(parent->rect()); + } +} + +void ToastManager::remove(Toast* toast) { + toast->hide(); + m_layout->removeWidget(toast); + toast->deleteLater(); +} + +} // namespace px::modrim diff --git a/src/ui/components/toast.hpp b/src/ui/components/toast.hpp new file mode 100644 index 0000000..f96696b --- /dev/null +++ b/src/ui/components/toast.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include "ui/context.hpp" + +class QGraphicsOpacityEffect; +class QPropertyAnimation; +class QVBoxLayout; + +namespace px::modrim { + +enum class ToastLevel { kInfo, kSuccess, kWarning, kError }; + +// One message card. Fades in on show; dismiss() fades it out and then emits +// dismissed(). +class Toast : public QWidget { + Q_OBJECT + + public: + Toast(const QString& message, ToastLevel level, QWidget* parent = nullptr); + + void showWithAnimation(); + void dismiss(); + + Q_SIGNALS: + void dismissed(); + + private: + QGraphicsOpacityEffect* m_opacityEffect = nullptr; + QPropertyAnimation* m_opacityAnim = nullptr; + QPropertyAnimation* m_fadeOutAnim = nullptr; + bool m_dismissing = false; +}; + +// Stacks toasts bottom-right over its parent. Durations are milliseconds; 0 +// keeps the toast until its close button is clicked. +class ToastManager : public QWidget, public Notifier { + Q_OBJECT + + public: + explicit ToastManager(QWidget* parent = nullptr); + + void showToast(const QString& message, ToastLevel level = ToastLevel::kInfo, + int durationMs = 3000); + + void info(const QString& message, int durationMs = 3000) override; + void success(const QString& message, int durationMs = 3000) override; + void warning(const QString& message, int durationMs = 4000) override; + void error(const QString& message, int durationMs = 5000) override; + + // Covers the parent's rectangle; call on every parent resize. + void resizeToParent(); + + private: + void remove(Toast* toast); + + QVBoxLayout* m_layout = nullptr; +}; + +} // namespace px::modrim diff --git a/src/ui/components/unity_rich_text.cpp b/src/ui/components/unity_rich_text.cpp new file mode 100644 index 0000000..1a7cc23 --- /dev/null +++ b/src/ui/components/unity_rich_text.cpp @@ -0,0 +1,164 @@ +#include "ui/components/unity_rich_text.hpp" + +#include +#include +#include +#include +#include +#include + +namespace px::modrim { +QString escapeHtml(const QString& text) { + QString out = text.toHtmlEscaped(); + return out.replace('\'', QStringLiteral("'")); +} +namespace { +QString unquote(QString value) { + while (!value.isEmpty() && (value.front() == '\'' || value.front() == '"')) + value.remove(0, 1); + while (!value.isEmpty() && (value.back() == '\'' || value.back() == '"')) + value.chop(1); + return value; +} +QString color(QString value) { + if (value.startsWith('#')) { + const QString hex = value.mid(1); + if (hex.size() == 3 || hex.size() == 4) { + QString expanded = "#"; + for (QChar c : hex) { + expanded += c; + expanded += c; + } + return expanded; + } + if (hex.size() == 6) return value; + if (hex.size() == 8) return value.left(7); + } + static const QHash names{ + {"red", "#ff0000"}, {"green", "#00ff00"}, {"blue", "#0000ff"}, + {"white", "#ffffff"}, {"black", "#000000"}, {"yellow", "#ffff00"}, + {"cyan", "#00ffff"}, {"magenta", "#ff00ff"}, {"gray", "#808080"}, + {"grey", "#808080"}, {"orange", "#ffa500"}, {"purple", "#800080"}}; + return names.value(value.toLower(), "#ffffff"); +} +QString closeTag(const QString& tag) { + if (tag == "b" || tag == "bold") return ""; + if (tag == "i" || tag == "italic") return ""; + if (tag == "u" || tag == "underline" || tag == "size") return ""; + if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; + if (tag == "color") return ""; + if (tag == "align" || tag == "indent") return ""; + if (tag == "a" || tag == "link") return ""; + if (tag == "mark" || tag == "sub" || tag == "sup") return ""; + return {}; +} +QString openTag(const QString& tag, QString attr) { + attr = unquote(attr); + if (tag == "b" || tag == "bold") return ""; + if (tag == "i" || tag == "italic") return ""; + if (tag == "u" || tag == "underline") + return ""; + if (tag == "s" || tag == "strike" || tag == "strikethrough") return ""; + if (tag == "br" || tag == "sub" || tag == "sup") return "<" + tag + ">"; + if (tag == "color") { + static const QRegularExpression valid("^(#?[0-9a-fA-F]{3,8}|[a-zA-Z]+)$"); + return ""; + } + if (tag == "size" || tag == "indent") { + static const QRegularExpression number( + "^([+-]?\\d+(?:\\.\\d+)?)(px|em|%)?$"); + auto match = number.match(attr); + double value = tag == "size" ? 13.0 : 20.0; + if (match.hasMatch()) { + bool ok = false; + const double raw = match.captured(1).toDouble(&ok); + if (ok && std::isfinite(raw)) { + const QString unit = match.captured(2); + if (tag == "size") { + if (unit == "%") + value = 12 * raw / 100; + else if (unit == "em") + value = 12 * raw; + else if (attr.startsWith('+') || attr.startsWith('-')) + value = 12 + raw; + else + value = raw; + if (!std::isfinite(value)) value = 13; + } else if (unit == "%") + value = 4 * raw; + else if (unit == "em") + value = 16 * raw; + else + value = raw; + } + } + if (tag == "size") + return ""; + return "
    (std::clamp(value, -10000.0, 10000.0))) + + "px;\">"; + } + if (tag == "align") { + attr = attr.toLower(); + if (attr == "justified" || attr == "flush") attr = "justify"; + if (attr != "left" && attr != "right" && attr != "center" && + attr != "justify") + attr = "left"; + return "
    "; + } + if (tag == "mark") { + QString background = + attr.isEmpty() ? QStringLiteral("#ffff00") : color(attr); + if (attr.size() == 9 && attr.startsWith('#')) { + bool ok; + int alpha = attr.right(2).toInt(&ok, 16); + if (ok) background += QString::number(alpha / 255.0, 'f', 2); + } + return ""; + } + if (tag == "a" || tag == "link") { + const QUrl url(attr); + const QString scheme = url.scheme().toLower(); + if ((scheme == "http" || scheme == "https") && !url.authority().isEmpty()) + return ""; + } + return {}; +} +} // namespace +QString unityRichTextToHtml(const QString& text) { + static const QRegularExpression tags("\\s]+))?\\s*>"); + QString result; + result.reserve(text.size()); + QStringList stack; + qsizetype end = 0; + auto matches = tags.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + result += escapeHtml(text.mid(end, match.capturedStart() - end)); + const QString tag = match.captured(1).toLower(); + if (match.captured().startsWith("= 0) { + while (stack.size() > index) stack.removeLast(); + result += closeTag(tag); + } + } else { + const QString html = openTag(tag, match.captured(2)); + if (!html.isEmpty()) { + stack.append(tag); + result += html; + } + } + end = match.capturedEnd(); + } + result += escapeHtml(text.mid(end)); + result.replace('\n', "
    "); + result.replace("\\n", "
    "); + for (auto it = stack.crbegin(); it != stack.crend(); ++it) + result += closeTag(*it); + return result; +} +} // namespace px::modrim diff --git a/src/ui/components/unity_rich_text.hpp b/src/ui/components/unity_rich_text.hpp new file mode 100644 index 0000000..3d3c7dc --- /dev/null +++ b/src/ui/components/unity_rich_text.hpp @@ -0,0 +1,6 @@ +#pragma once +#include +namespace px::modrim { +QString escapeHtml(const QString& text); +QString unityRichTextToHtml(const QString& text); +} // namespace px::modrim diff --git a/src/ui/components/view_rail_panel.cpp b/src/ui/components/view_rail_panel.cpp new file mode 100644 index 0000000..15bc2db --- /dev/null +++ b/src/ui/components/view_rail_panel.cpp @@ -0,0 +1,46 @@ +#include "ui/components/view_rail_panel.hpp" + +#include +#include +#include + +#include + +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +ViewRailPanel::ViewRailPanel(const QVariantList& tabs, + const QVariantList& helpEntries, + QQmlEngine* engine, QWidget* parent) + : QQuickWidget(engine, parent) { + setResizeMode(QQuickWidget::SizeRootObjectToView); + setClearColor(paletteColor(u"ELEVATE_2")); + setMinimumWidth(kRailMinWidth); + setMaximumWidth(kRailMaxWidth); + + rootContext()->setContextProperty(QStringLiteral("railModel"), tabs); + rootContext()->setContextProperty(QStringLiteral("railHelpEntries"), + helpEntries); + setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/IconRail.qml"))); + + QObject* root = rootObject(); + if (root == nullptr) { + spdlog::error("IconRail.qml failed to load: rootObject is null"); + return; + } + connect(root, SIGNAL(tabSelected(int)), this, SIGNAL(currentChanged(int))); + connect(root, SIGNAL(tabHovered(int)), this, SIGNAL(hovered(int))); + connect(root, SIGNAL(settingsRequested()), this, SIGNAL(settingsRequested())); + connect(root, SIGNAL(helpActionRequested(QString)), this, + SIGNAL(helpActionRequested(QString))); +} + +void ViewRailPanel::setCurrent(int index) { + if (QObject* root = rootObject()) { + root->setProperty("currentIndex", index); + } +} + +} // namespace px::modrim diff --git a/src/ui/components/view_rail_panel.hpp b/src/ui/components/view_rail_panel.hpp new file mode 100644 index 0000000..9409061 --- /dev/null +++ b/src/ui/components/view_rail_panel.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +class QQmlEngine; +class QWidget; + +namespace px::modrim { + +// Hosts IconRail.qml, the left navigation rail. `tabs` are maps with `icon` and +// `label` keys; `helpEntries` are maps with `id` and `label` keys for the Help +// menu. +class ViewRailPanel : public QQuickWidget { + Q_OBJECT + + public: + ViewRailPanel(const QVariantList& tabs, const QVariantList& helpEntries, + QQmlEngine* engine, QWidget* parent = nullptr); + + // Moves the highlighted tab without emitting currentChanged. + void setCurrent(int index); + + signals: + void currentChanged(int index); + void hovered(int index); + void settingsRequested(); + void helpActionRequested(const QString& actionId); +}; + +} // namespace px::modrim diff --git a/src/ui/config.cpp b/src/ui/config.cpp new file mode 100644 index 0000000..e843fac --- /dev/null +++ b/src/ui/config.cpp @@ -0,0 +1,40 @@ +#include "ui/config.hpp" + +#include + +namespace px::modrim { + +namespace { + +const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); + +} // namespace + +Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc) { + return svc.save(kUiPrefsFile, prefs); +} + +UiPrefsService::UiPrefsService(ConfigService& svc) : m_svc(svc) {} + +QCoro::Task> UiPrefsService::setup() { + auto loaded = m_svc.load(kUiPrefsFile); + if (!loaded) { + co_return std::unexpected(loaded.error()); + } + m_prefs = std::move(*loaded); + co_return {}; +} + +const UiPrefs& UiPrefsService::prefs() const { + Q_ASSERT(m_prefs.has_value()); + return *m_prefs; +} + +Result UiPrefsService::savePrefs() const { + if (!m_prefs) { + return {}; + } + return saveUiPrefs(*m_prefs, m_svc); +} + +} // namespace px::modrim diff --git a/src/ui/config.hpp b/src/ui/config.hpp new file mode 100644 index 0000000..4d75338 --- /dev/null +++ b/src/ui/config.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include + +#include "core/config.hpp" +#include "core/error.hpp" +#include "ui/ui_prefs.hpp" + +namespace px::modrim { + +// Writes ui_prefs.json. Other UI code calls this after changing prefs. +Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc); + +// Owns the UiPrefs lifecycle: loaded once in setup(), saved on request. The +// ConfigService must outlive this object. +class UiPrefsService { + public: + explicit UiPrefsService(ConfigService& svc); + + QCoro::Task> setup(); + + // Valid only after a successful setup(). + const UiPrefs& prefs() const; + + Result savePrefs() const; + + private: + ConfigService& m_svc; + std::optional m_prefs; +}; + +} // namespace px::modrim diff --git a/src/ui/context.cpp b/src/ui/context.cpp new file mode 100644 index 0000000..2ecac5b --- /dev/null +++ b/src/ui/context.cpp @@ -0,0 +1,78 @@ +#include "ui/context.hpp" + +#include +#include + +#include "core/mod_service.hpp" + +namespace px::modrim { + +RailView::RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app) + : QWidget(parent), m_ctx(ctx), m_engine(engine), m_app(app) {} + +void RailView::openRoute(const QStringList&) {} + +void RailView::preload() {} + +void RailView::teardown() {} + +AppContext::AppContext(CoreContext& core, UiPrefs prefs) + : m_core(core), m_uiPrefs(std::move(prefs)) {} + +AppContext::~AppContext() = default; + +void AppContext::registerPlugin(std::unique_ptr plugin) { + m_plugins.registerPlugin(std::move(plugin)); +} + +void AppContext::addSettingsSection(SettingsSectionFactory factory) { + m_settingsSections.push_back(std::move(factory)); +} + +void AppContext::navigate(const QString& url) const { + if (m_navigate) { + m_navigate(url); + } +} + +void AppContext::setNavigator(std::function navigate) { + m_navigate = std::move(navigate); +} + +Result AppContext::setupAll() { + if (auto core = m_core.plugins().setupAll(m_core); !core) { + return std::unexpected(core.error()); + } + return m_plugins.setupAll(*this, m_core.plugins().names()); +} + +QCoro::Task> AppContext::initAll() { + if (m_pluginsInitialized) { + co_return {}; + } + if (auto core = co_await m_core.plugins().initAll(m_core); !core) { + co_return std::unexpected(core.error()); + } + if (auto ui = co_await m_plugins.initAll(*this); !ui) { + co_return std::unexpected(ui.error()); + } + m_pluginsInitialized = true; + co_return {}; +} + +QCoro::Task> AppContext::refreshMods(bool full) { + if (auto reloaded = co_await m_core.modService().reload(full); !reloaded) { + co_return std::unexpected(reloaded.error()); + } + co_return static_cast(m_core.allMods().size()); +} + +QCoro::Task> AppContext::shutdownAll() { + if (auto ui = co_await m_plugins.shutdownAll(); !ui) { + co_return std::unexpected(ui.error()); + } + co_return co_await m_core.plugins().shutdownAll(); +} + +} // namespace px::modrim diff --git a/src/ui/context.hpp b/src/ui/context.hpp new file mode 100644 index 0000000..13cccdb --- /dev/null +++ b/src/ui/context.hpp @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "core/error.hpp" +#include "core/plugin.hpp" +#include "ui/settings_section.hpp" +#include "ui/ui_prefs.hpp" + +class QQmlEngine; + +namespace px::modrim { + +class AppContext; + +// UI-layer plugins: same lifecycle as core plugins, but their context is the +// AppContext. +using UiPlugin = BasicPlugin; +using UiPluginRegistry = BasicPluginRegistry; + +// Toast sink. Durations are milliseconds; the defaults match the legacy toast +// manager. +class Notifier { + public: + virtual ~Notifier() = default; + + virtual void info(const QString& message, int durationMs = 3000) = 0; + virtual void success(const QString& message, int durationMs = 3000) = 0; + virtual void warning(const QString& message, int durationMs = 4000) = 0; + virtual void error(const QString& message, int durationMs = 5000) = 0; +}; + +// Class-level metadata of a rail view, read before the view is constructed. +// Each RailView subclass declares `static constexpr RailViewMeta kRailMeta`. +struct RailViewMeta { + QLatin1StringView viewId; + QLatin1StringView iconName; + QLatin1StringView label; + // The download queue: the header's downloads button opens it, and the header + // hides its own download progress while it is shown. + bool listsDownloads = false; +}; + +// Base of widgets registered on the navigation rail. +class RailView : public QWidget { + Q_OBJECT + + public: + RailView(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + + // Handles the part of a `modrim:///…` link after the view id. + virtual void openRoute(const QStringList& path); + + // Called when the rail reveals the view ahead of its first use, and when the + // window closes. Views that hold resources (e.g. web profiles) release them + // in teardown(). + virtual void preload(); + virtual void teardown(); + + protected: + CoreContext& m_ctx; + QQmlEngine* m_engine; + AppContext& m_app; +}; + +using RailViewFactory = + std::function; + +struct RailViewSpec { + RailViewMeta meta; + RailViewFactory create; +}; + +template +concept RailViewType = + std::derived_from && requires { V::kRailMeta; }; + +// The UI composition context: owns the UI plugin registry, the rail and +// settings contributions, the toast sink and the navigator. Core state is read +// through coreContext(), which must outlive this object. +class AppContext { + public: + AppContext(CoreContext& core, UiPrefs prefs = {}); + AppContext(const AppContext&) = delete; + AppContext& operator=(const AppContext&) = delete; + AppContext(AppContext&&) = delete; + AppContext& operator=(AppContext&&) = delete; + ~AppContext(); + + // ── Plugin system (UI layer) ────────────────────── + void registerPlugin(std::unique_ptr plugin); + UiPluginRegistry& plugins() noexcept { return m_plugins; } + + // ── Rail views ──────────────────────────────────── + // Appends, or inserts at `position` when given. + template + void addRailView(std::optional position = std::nullopt); + const std::vector& railViews() const noexcept { + return m_railViews; + } + + // ── Settings sections ───────────────────────────── + // Each section is built per dialog, parented to that dialog. + void addSettingsSection(SettingsSectionFactory factory); + const std::vector& settingsSections() const noexcept { + return m_settingsSections; + } + + // ── Toasts ──────────────────────────────────────── + // Not owned; nullptr when no toast surface is shown. + Notifier* toasts() const noexcept { return m_toasts; } + void setToasts(Notifier* notifier) noexcept { m_toasts = notifier; } + + // ── Navigation ──────────────────────────────────── + // Opens a `modrim://[/…]` link; a no-op until a navigator is set. + void navigate(const QString& url) const; + void setNavigator(std::function navigate); + + // ── UI prefs ────────────────────────────────────── + UiPrefs& uiPrefs() noexcept { return m_uiPrefs; } + const UiPrefs& uiPrefs() const noexcept { return m_uiPrefs; } + + // ── Core access ─────────────────────────────────── + CoreContext& coreContext() const noexcept { return m_core; } + + // ── Lifecycle ───────────────────────────────────── + // Core plugins first, then UI plugins; UI plugins may depend on core plugin + // names. + Result setupAll(); + QCoro::Task> initAll(); + bool pluginsInitialized() const noexcept { return m_pluginsInitialized; } + // Reloads mods and resolves to how many are now loaded. + QCoro::Task> refreshMods(bool full = false); + // UI plugins shut down before core plugins. + QCoro::Task> shutdownAll(); + + private: + CoreContext& m_core; + UiPluginRegistry m_plugins; + std::vector m_railViews; + std::vector m_settingsSections; + Notifier* m_toasts = nullptr; + std::function m_navigate; + UiPrefs m_uiPrefs; + bool m_pluginsInitialized = false; +}; + +template +void AppContext::addRailView(std::optional position) { + RailViewSpec spec{ + V::kRailMeta, + [](CoreContext& ctx, QQmlEngine* engine, QWidget* parent, AppContext& app) + -> RailView* { return new V(ctx, engine, parent, app); }, + }; + if (position) { + m_railViews.insert( + m_railViews.begin() + static_cast(*position), + std::move(spec)); + } else { + m_railViews.push_back(std::move(spec)); + } +} + +} // namespace px::modrim diff --git a/src/ui/mod_selection.cpp b/src/ui/mod_selection.cpp new file mode 100644 index 0000000..cd7fd27 --- /dev/null +++ b/src/ui/mod_selection.cpp @@ -0,0 +1,61 @@ +#include "ui/mod_selection.hpp" + +#include + +#include "core/services/diagnostics_service.hpp" + +namespace px::modrim { + +ModSelectionPresenter::ModSelectionPresenter(CoreContext& ctx, + ModInfoSink& sink) + : m_ctx(ctx), m_sink(sink) {} + +QCoro::Task ModSelectionPresenter::show(QString uuid) { + const std::uint64_t generation = ++m_generation; + + if (uuid.isEmpty()) { + m_sink.clear(); + co_return; + } + + const auto& mods = m_ctx.allMods(); + const auto found = mods.constFind(uuid); + if (found == mods.cend()) { + m_sink.clear(); + co_return; + } + + const ListedMod& mod = *found.value(); + m_sink.showMod(mod, m_ctx.diagnosticsService().issuesFor(uuid)); + + std::optional packageId; + if (const auto* about = dynamic_cast(&mod)) { + packageId = about->packageId.str(); + } + const QStringList activePackageIds = resolveActivePackageIds(); + + if (generation != m_generation) { + co_return; + } + co_await m_sink.setTimeAnalytics(packageId, activePackageIds); +} + +void ModSelectionPresenter::clear() { + ++m_generation; + m_sink.clear(); +} + +QStringList ModSelectionPresenter::resolveActivePackageIds() const { + const QSet active(m_ctx.activeUuids().cbegin(), + m_ctx.activeUuids().cend()); + QStringList packageIds; + for (const ListedModPtr& mod : m_ctx.allMods()) { + const auto* about = dynamic_cast(mod.get()); + if (about && active.contains(about->uuid())) { + packageIds.append(about->packageId.str()); + } + } + return packageIds; +} + +} // namespace px::modrim diff --git a/src/ui/mod_selection.hpp b/src/ui/mod_selection.hpp new file mode 100644 index 0000000..cb6c465 --- /dev/null +++ b/src/ui/mod_selection.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/models/view/diagnostics.hpp" + +namespace px::modrim { + +// The mod-details surface the presenter drives. The mod info panel implements +// it. +class ModInfoSink { + public: + virtual ~ModInfoSink() = default; + + virtual void clear() = 0; + virtual void showMod(const ListedMod& mod, + const QList& issues) = 0; + // `packageId` is nullopt for mods that have no package id (non-About.xml + // mods). + virtual QCoro::Task setTimeAnalytics( + const std::optional& packageId, + const QStringList& activePackageIds) = 0; +}; + +// Loads a selected mod's details into a ModInfoSink for any view. +class ModSelectionPresenter { + public: + ModSelectionPresenter(CoreContext& ctx, ModInfoSink& sink); + + // A stale show() (superseded by a later one or clear()) stops before its + // analytics await. + QCoro::Task show(QString uuid); + void clear(); + + private: + QStringList resolveActivePackageIds() const; + + CoreContext& m_ctx; + ModInfoSink& m_sink; + std::uint64_t m_generation = 0; +}; + +} // namespace px::modrim diff --git a/src/ui/models/impact.cpp b/src/ui/models/impact.cpp new file mode 100644 index 0000000..5707581 --- /dev/null +++ b/src/ui/models/impact.cpp @@ -0,0 +1,25 @@ +#include "ui/models/impact.hpp" + +#include + +#include "core/services/startup_impact_service/models.hpp" +#include "ui/theme/palette.hpp" +namespace px::modrim { +QString formatDuration(double seconds) { + if (seconds < 0.001) return QStringLiteral("0 ms"); + if (seconds < 1) + return QStringLiteral("%1 ms").arg( + static_cast(std::nearbyint(seconds * 1000))); + if (seconds < 60) return QStringLiteral("%1 s").arg(seconds, 0, 'f', 2); + const auto rounded = static_cast(std::nearbyint(seconds)); + return QStringLiteral("%1m %2s") + .arg(rounded / 60) + .arg(rounded % 60, 2, 10, QLatin1Char('0')); +} +QString impactColor(double seconds) { + using namespace startup_impact; + if (seconds < kImpactWarnThresholdS) return paletteValue(u"SUCCESS"); + if (seconds < kImpactHighThresholdS) return paletteValue(u"WARNING"); + return paletteValue(u"DANGER"); +} +} // namespace px::modrim diff --git a/src/ui/models/impact.hpp b/src/ui/models/impact.hpp new file mode 100644 index 0000000..a020554 --- /dev/null +++ b/src/ui/models/impact.hpp @@ -0,0 +1,6 @@ +#pragma once +#include +namespace px::modrim { +QString formatDuration(double seconds); +QString impactColor(double seconds); +} // namespace px::modrim diff --git a/src/ui/models/mod_list_model.cpp b/src/ui/models/mod_list_model.cpp new file mode 100644 index 0000000..2a27546 --- /dev/null +++ b/src/ui/models/mod_list_model.cpp @@ -0,0 +1,301 @@ +#include "ui/models/mod_list_model.hpp" + +#include +#include + +#include "core/services/diagnostics_service.hpp" +#include "ui/models/impact.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +QString providerLabel(const QString& providerId, const QString& packageId) { + const auto pid = providerId.trimmed().toLower(); + const auto pkg = packageId.trimmed().toLower(); + if (pid == "steam" || pid == "downloaded") return QStringLiteral("Workshop"); + if (pid == "local") return QStringLiteral("Local"); + if (pid == "core") + return !pkg.isEmpty() && pkg != "ludeon.rimworld" ? QStringLiteral("DLC") + : QStringLiteral("Core"); + if (pkg.startsWith("ludeon.rimworld.")) return QStringLiteral("DLC"); + if (pkg == "ludeon.rimworld") return QStringLiteral("Core"); + return pid.isEmpty() ? pid : pid.first(1).toUpper() + pid.sliced(1); +} +QString sectionName(bool checked, int activeCount, int inactiveCount) { + return checked || activeCount == 0 || inactiveCount == 0 + ? QString{} + : QStringLiteral("Inactive (%1)").arg(inactiveCount); +} +ModListModel::ModListModel(QHash colors, + DiagnosticsService* diagnostics, QObject* parent) + : QAbstractListModel(parent), + m_providerColors(std::move(colors)), + m_diagnostics(diagnostics) { + if (m_diagnostics) + m_diagnosticsConnection = m_diagnostics->diagnosticsSummaryChanged.connect( + [this](const auto& summaries) { diagnosticsChanged(summaries); }); +} +ModListModel::~ModListModel() { + if (m_diagnostics) + m_diagnostics->diagnosticsSummaryChanged.disconnect( + m_diagnosticsConnection); +} +int ModListModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_items.size()); +} +const ModItem* ModListModel::getItem(int row) const { + return row >= 0 && row < m_items.size() ? &m_items[row] : nullptr; +} +const ModItem* ModListModel::getItemByUuid(const QString& uuid) const { + for (const auto& item : m_items) + if (item.uuid == uuid) return &item; + return nullptr; +} +QVariant ModListModel::data(const QModelIndex& index, int role) const { + const auto* item = index.isValid() ? getItem(index.row()) : nullptr; + if (!item) return {}; + const auto* about = dynamic_cast(item->mod.get()); + switch (role) { + case Qt::DisplayRole: + return item->mod->name; + case Qt::ToolTipRole: + return item->mod->description; + case kCheckStateRole: + return static_cast(item->checked ? Qt::Checked : Qt::Unchecked); + case kPackageIdRole: + return about ? about->packageId.str() : QString{}; + case kModVersionRole: + return about ? about->modVersion : QString{}; + case kProviderColorRole: + return item->providerColor; + case kUuidRole: + return item->uuid; + case kStartupImpactRole: + return item->startupImpactS; + case kStartupImpactColorRole: + return impactColor(item->startupImpactS); + case kStartupImpactTextRole: + return formatDuration(item->startupImpactS); + case kLoadIndexRole: + return item->loadIndex; + case kIsActiveRole: + return item->checked; + case kSectionNameRole: + return sectionName(item->checked, m_activeCount, + static_cast(m_items.size()) - m_activeCount); + case kProviderLabelRole: + return item->providerLabel; + case kHasErrorRole: + case kHasWarningRole: + case kErrorTooltipRole: + case kWarningTooltipRole: { + const auto diag = + m_diagnostics ? m_diagnostics->summaryFor(item->uuid) : std::nullopt; + if (role == kHasErrorRole) return diag && diag->hasErrors ? 1 : 0; + if (role == kHasWarningRole) return diag && diag->hasWarnings ? 1 : 0; + if (role == kErrorTooltipRole) + return diag ? diag->errorTooltip : QString{}; + return diag ? diag->warningTooltip : QString{}; + } + default: + return {}; + } +} +bool ModListModel::setData(const QModelIndex& index, const QVariant& value, + int role) { + if (!index.isValid() || !getItem(index.row()) || role != kCheckStateRole) + return false; + setCheckStates({index.row()}, value.toInt() == Qt::Checked); + return true; +} +Qt::ItemFlags ModListModel::flags(const QModelIndex& index) const { + if (!index.isValid()) return Qt::ItemIsDropEnabled; + return Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled | + Qt::ItemIsUserCheckable; +} +QHash ModListModel::roleNames() const { + return {{Qt::DisplayRole, "name"}, + {kCheckStateRole, "checkState"}, + {kPackageIdRole, "packageId"}, + {kModVersionRole, "modVersion"}, + {kProviderColorRole, "providerColor"}, + {kUuidRole, "uuid"}, + {kHasErrorRole, "hasError"}, + {kHasWarningRole, "hasWarning"}, + {kErrorTooltipRole, "errorTooltip"}, + {kWarningTooltipRole, "warningTooltip"}, + {kStartupImpactRole, "startupImpact"}, + {kLoadIndexRole, "loadIndex"}, + {kIsActiveRole, "isActive"}, + {kSectionNameRole, "sectionName"}, + {kProviderLabelRole, "providerLabel"}, + {kStartupImpactColorRole, "startupImpactColor"}, + {kStartupImpactTextRole, "startupImpactText"}, + {Qt::ToolTipRole, "toolTip"}}; +} +void ModListModel::updateProviderColors(QHash colors) { + m_providerColors = std::move(colors); +} +ModItem ModListModel::makeItem(const QString& uuid, ListedModPtr mod, + bool checked) const { + const auto* about = dynamic_cast(mod.get()); + auto label = providerLabel(mod->providerId, + about ? about->packageId.str() : QString{}); + auto color = + m_providerColors.value(mod->providerId, paletteValue(u"TEXT_DIM")); + return {std::move(mod), uuid, checked, std::move(color), 0, -1, + std::move(label)}; +} +void ModListModel::loadMods(const QHash& mods, + const QStringList& activeUuids) { + beginResetModel(); + m_items.clear(); + m_items.reserve(mods.size()); + const QSet active(activeUuids.begin(), activeUuids.end()); + for (const auto& uuid : activeUuids) { + const auto it = mods.constFind(uuid); + if (it != mods.cend()) m_items.append(makeItem(uuid, it.value(), true)); + } + QList inactive; + inactive.reserve(mods.size() - m_items.size()); + for (auto it = mods.cbegin(); it != mods.cend(); ++it) + if (!active.contains(it.key())) + inactive.append(makeItem(it.key(), it.value(), false)); + std::stable_sort(inactive.begin(), inactive.end(), + [](const auto& a, const auto& b) { + return a.mod->name.toLower() < b.mod->name.toLower(); + }); + m_items.append(std::move(inactive)); + updateLoadIndices(); + endResetModel(); +} +QStringList ModListModel::activeUuids() const { + QStringList result; + result.reserve(m_activeCount); + for (const auto& item : m_items) + if (item.checked) result.append(item.uuid); + return result; +} +std::pair ModListModel::updateLoadIndices() { + int first = rowCount(), last = -1; + int count = 0; + for (int row = 0; row < m_items.size(); ++row) { + auto& item = m_items[row]; + const int index = item.checked ? ++count : -1; + if (item.loadIndex != index) { + item.loadIndex = index; + first = std::min(first, row); + last = row; + } + } + if (m_activeCount != count) { + m_activeCount = count; + emit activeCountChanged(); + } + return {first, last}; +} +bool ModListModel::moveRow(int source, int target) { + if (source == target || !getItem(source) || !getItem(target)) return false; + beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); + m_items.move(source, target); + endMoveRows(); + const auto [first, last] = updateLoadIndices(); + if (last >= 0) + emit dataChanged(index(first), index(last), + {kLoadIndexRole, kSectionNameRole}); + return true; +} +void ModListModel::commitOrder(const QStringList& uuids) { + QHash oldRows; + for (int row = 0; row < m_items.size(); ++row) + oldRows.insert(m_items[row].uuid, row); + QList order; + order.reserve(m_items.size()); + QSet ordered; + for (const auto& uuid : uuids) + if (const auto found = oldRows.constFind(uuid); found != oldRows.cend()) { + order.append(found.value()); + ordered.insert(uuid); + } + QList inactive; + for (int row = 0; row < m_items.size(); ++row) + if (!ordered.contains(m_items[row].uuid)) { + if (m_items[row].checked) + order.append(row); + else + inactive.append(row); + } + std::stable_sort(inactive.begin(), inactive.end(), [this](int a, int b) { + return m_items[a].mod->name.toLower() < m_items[b].mod->name.toLower(); + }); + order.append(inactive); + bool same = order.size() == m_items.size(); + for (int i = 0; same && i < order.size(); ++i) same = order[i] == i; + if (same) return; + emit layoutAboutToBeChanged(); + const auto persistent = persistentIndexList(); + QList next; + next.reserve(order.size()); + QHash newRows; + for (int row = 0; row < order.size(); ++row) { + newRows.insert(order[row], row); + next.append(m_items[order[row]]); + } + m_items = std::move(next); + updateLoadIndices(); + QModelIndexList replacements; + replacements.reserve(persistent.size()); + for (const auto& previous : persistent) + replacements.append(index(newRows.value(previous.row(), -1))); + changePersistentIndexList(persistent, replacements); + emit layoutChanged(); +} +void ModListModel::setCheckable(const QSet& uuids, bool checked) { + QList rows; + for (int row = 0; row < m_items.size(); ++row) + if (uuids.contains(m_items[row].uuid) && m_items[row].checked != checked) + rows.append(row); + setCheckStates(rows, checked); +} +void ModListModel::setCheckStates(const QList& rows, bool checked) { + int first = static_cast(m_items.size()); + for (int row : rows) + if (getItem(row) && m_items[row].checked != checked) { + m_items[row].checked = checked; + first = std::min(first, row); + } + if (first == m_items.size()) return; + first = std::min(first, updateLoadIndices().first); + emit dataChanged( + index(first), index(rowCount() - 1), + {kCheckStateRole, kLoadIndexRole, kIsActiveRole, kSectionNameRole}); + emit activeModsChanged(); +} +void ModListModel::setStartupImpact(const QHash& impacts) { + if (m_items.isEmpty()) return; + for (auto& item : m_items) { + const auto* about = dynamic_cast(item.mod.get()); + item.startupImpactS = about ? impacts.value(about->packageId.str()) : 0; + } + emit dataChanged( + index(0), index(rowCount() - 1), + {kStartupImpactRole, kStartupImpactColorRole, kStartupImpactTextRole}); +} +void ModListModel::diagnosticsChanged( + const QHash& summaries) { + if (m_items.isEmpty()) return; + int first = rowCount(), last = -1; + for (int row = 0; row < m_items.size(); ++row) { + const auto& uuid = m_items[row].uuid; + if (m_lastDiagnostics.contains(uuid) != summaries.contains(uuid) || + m_lastDiagnostics.value(uuid) != summaries.value(uuid)) { + first = std::min(first, row); + last = row; + } + } + m_lastDiagnostics = summaries; + if (last >= 0) + emit dataChanged(index(first), index(last), + {kHasErrorRole, kHasWarningRole, kErrorTooltipRole, + kWarningTooltipRole}); +} +} // namespace px::modrim diff --git a/src/ui/models/mod_list_model.hpp b/src/ui/models/mod_list_model.hpp new file mode 100644 index 0000000..ade96e6 --- /dev/null +++ b/src/ui/models/mod_list_model.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/models/metadata/structures.hpp" +#include "core/models/view/diagnostics.hpp" + +namespace px::modrim { +class DiagnosticsService; +} +namespace px::modrim { +QString providerLabel(const QString& providerId, const QString& packageId = {}); +QString sectionName(bool checked, int activeCount, int inactiveCount); + +struct ModItem { + ListedModPtr mod; + QString uuid; + bool checked = false; + QString providerColor; + double startupImpactS = 0; + int loadIndex = -1; + QString providerLabel; +}; + +class ModListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) + public: + enum Role { + kCheckStateRole = Qt::UserRole + 1, + kPackageIdRole, + kModVersionRole, + kProviderColorRole, + kUuidRole, + kHasErrorRole, + kHasWarningRole, + kErrorTooltipRole, + kWarningTooltipRole, + kStartupImpactRole, + kLoadIndexRole, + kIsActiveRole, + kSectionNameRole, + kProviderLabelRole, + kStartupImpactColorRole, + kStartupImpactTextRole + }; + Q_ENUM(Role) + explicit ModListModel(QHash providerColors = {}, + DiagnosticsService* diagnostics = nullptr, + QObject* parent = nullptr); + ~ModListModel() override; + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QHash roleNames() const override; + void updateProviderColors(QHash colors); + void loadMods(const QHash& mods, + const QStringList& activeUuids); + QStringList activeUuids() const; + int activeCount() const { return m_activeCount; } + const ModItem* getItem(int row) const; + const ModItem* getItemByUuid(const QString& uuid) const; + bool moveRow(int sourceRow, int targetRow); + void commitOrder(const QStringList& uuids); + void setCheckable(const QSet& uuids, bool checked); + void setCheckStates(const QList& rows, bool checked); + void setStartupImpact(const QHash& impacts = {}); + signals: + void activeModsChanged(); + void activeCountChanged(); + + private: + std::pair updateLoadIndices(); + ModItem makeItem(const QString& uuid, ListedModPtr mod, bool checked) const; + void diagnosticsChanged( + const QHash& summaries); + QHash m_providerColors; + QList m_items; + int m_activeCount = 0; + DiagnosticsService* m_diagnostics; + std::uint64_t m_diagnosticsConnection = 0; + QHash m_lastDiagnostics; +}; +} // namespace px::modrim diff --git a/src/ui/models/mod_list_proxy_model.cpp b/src/ui/models/mod_list_proxy_model.cpp new file mode 100644 index 0000000..49c6db8 --- /dev/null +++ b/src/ui/models/mod_list_proxy_model.cpp @@ -0,0 +1,183 @@ +#include "ui/models/mod_list_proxy_model.hpp" + +#include +#include +namespace px::modrim { +ModListProxyModel::ModListProxyModel(ModListModel* source, QObject* parent) + : QAbstractListModel(parent), m_source(source) { + connect(m_source, &QAbstractItemModel::dataChanged, this, + &ModListProxyModel::sourceDataChanged); + const auto rebuildLayout = [this] { + if (!m_inProxyMove) layoutRebuild(); + }; + connect(m_source, &QAbstractItemModel::layoutChanged, this, rebuildLayout); + connect(m_source, &QAbstractItemModel::modelReset, this, [this] { + beginResetModel(); + rebuild(); + endResetModel(); + }); + connect(m_source, &QAbstractItemModel::rowsMoved, this, rebuildLayout); + connect(m_source, &QAbstractItemModel::rowsInserted, this, rebuildLayout); + connect(m_source, &QAbstractItemModel::rowsRemoved, this, rebuildLayout); + rebuild(); +} +int ModListProxyModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_visibleUuids.size()); +} +void ModListProxyModel::rebuildSourceMapping() { + m_sourceRows.clear(); + for (int row = 0; row < m_source->rowCount(); ++row) + m_sourceRows.insert(m_source->getItem(row)->uuid, row); +} +bool ModListProxyModel::acceptItem(const ModItem& item) const { + if (m_sidebarUuids && !m_sidebarUuids->contains(item.uuid)) return false; + if (m_searchText.isEmpty() || + item.mod->name.trimmed().toCaseFolded().contains(m_searchText)) + return true; + const auto* about = dynamic_cast(item.mod.get()); + return about && + about->packageId.str().trimmed().toCaseFolded().contains(m_searchText); +} +void ModListProxyModel::rebuild() { + rebuildSourceMapping(); + m_visibleUuids.clear(); + m_proxyRows.clear(); + for (int row = 0; row < m_source->rowCount(); ++row) { + const auto& item = *m_source->getItem(row); + if (acceptItem(item)) { + m_proxyRows.insert(item.uuid, static_cast(m_visibleUuids.size())); + m_visibleUuids.append(item.uuid); + } + } + updateVisibleCounts(); +} +void ModListProxyModel::updateVisibleCounts() { + int active = 0; + for (const auto& uuid : m_visibleUuids) { + const auto* item = m_source->getItem(m_sourceRows.value(uuid, -1)); + if (item && item->checked) ++active; + } + const bool changed = active != m_visibleActiveCount; + m_visibleActiveCount = active; + m_visibleInactiveCount = static_cast(m_visibleUuids.size()) - active; + if (changed) emit activeCountChanged(); +} +int ModListProxyModel::proxyRowForUuid(const QString& uuid) const { + return m_proxyRows.value(uuid, -1); +} +QModelIndex ModListProxyModel::mapToSource(const QModelIndex& index) const { + if (!index.isValid() || index.row() < 0 || + index.row() >= m_visibleUuids.size()) + return {}; + return m_source->index(m_sourceRows.value(m_visibleUuids[index.row()], -1)); +} +QModelIndex ModListProxyModel::mapFromSource( + const QModelIndex& sourceIndex) const { + const auto* item = + sourceIndex.isValid() ? m_source->getItem(sourceIndex.row()) : nullptr; + return item ? index(proxyRowForUuid(item->uuid)) : QModelIndex{}; +} +QVariant ModListProxyModel::data(const QModelIndex& proxyIndex, + int role) const { + const auto sourceIndex = mapToSource(proxyIndex); + if (!sourceIndex.isValid()) return {}; + if (role == ModListModel::kSectionNameRole) { + return sectionName(m_source->getItem(sourceIndex.row())->checked, + m_visibleActiveCount, m_visibleInactiveCount); + } + return m_source->data(sourceIndex, role); +} +bool ModListProxyModel::setData(const QModelIndex& index, const QVariant& value, + int role) { + const auto sourceIndex = mapToSource(index); + return sourceIndex.isValid() && m_source->setData(sourceIndex, value, role); +} +Qt::ItemFlags ModListProxyModel::flags(const QModelIndex& index) const { + if (!index.isValid()) return Qt::ItemIsDropEnabled; + const auto sourceIndex = mapToSource(index); + return sourceIndex.isValid() ? m_source->flags(sourceIndex) : Qt::NoItemFlags; +} +QHash ModListProxyModel::roleNames() const { + return m_source->roleNames(); +} +void ModListProxyModel::layoutRebuild() { + const auto persistent = persistentIndexList(); + QStringList uuids; + uuids.reserve(persistent.size()); + for (const auto& index : persistent) + uuids.append(index.isValid() && index.row() < m_visibleUuids.size() + ? m_visibleUuids[index.row()] + : QString{}); + emit layoutAboutToBeChanged(); + rebuild(); + QModelIndexList replacements; + replacements.reserve(uuids.size()); + for (const auto& uuid : uuids) + replacements.append(index(proxyRowForUuid(uuid))); + changePersistentIndexList(persistent, replacements); + emit layoutChanged(); +} +void ModListProxyModel::setSearchFilter(const QString& text) { + const auto normalized = text.trimmed().toCaseFolded(); + if (normalized == m_searchText) return; + const bool wasFiltered = isFiltered(); + m_searchText = normalized; + layoutRebuild(); + if (wasFiltered != isFiltered()) emit isFilteredChanged(); +} +void ModListProxyModel::setSidebarFilter(std::optional> uuids) { + if (m_sidebarUuids == uuids) return; + const bool wasFiltered = isFiltered(); + m_sidebarUuids = std::move(uuids); + layoutRebuild(); + if (wasFiltered != isFiltered()) emit isFilteredChanged(); +} +bool ModListProxyModel::moveRow(int source, int target, bool clampToActive) { + if (source < 0 || source >= m_visibleUuids.size()) return false; + const auto uuid = m_visibleUuids[source]; + const int sourceRow = m_sourceRows.value(uuid, -1); + if (clampToActive) { + const auto* item = m_source->getItem(sourceRow); + if (isFiltered() || !item || !item->checked) return false; + target = std::max(0, std::min(target, m_visibleActiveCount - 1)); + } + if (source == target || target < 0 || target >= m_visibleUuids.size()) + return false; + const int targetRow = m_sourceRows.value(m_visibleUuids[target], -1); + if (sourceRow < 0 || targetRow < 0) return false; + // Source data signals refer to post-move rows, while the proxy is still + // moving. + m_inProxyMove = true; + beginMoveRows({}, source, source, {}, source < target ? target + 1 : target); + m_visibleUuids.move(source, target); + m_proxyRows.clear(); + for (int row = 0; row < m_visibleUuids.size(); ++row) + m_proxyRows.insert(m_visibleUuids[row], row); + m_source->moveRow(sourceRow, targetRow); + rebuildSourceMapping(); + endMoveRows(); + m_inProxyMove = false; + emit dataChanged( + index(std::min(source, target)), index(std::max(source, target)), + {ModListModel::kLoadIndexRole, ModListModel::kSectionNameRole}); + return true; +} +void ModListProxyModel::sourceDataChanged(const QModelIndex& top, + const QModelIndex& bottom, + const QList& roles) { + if (m_inProxyMove) return; + if (roles.isEmpty() || roles.contains(ModListModel::kCheckStateRole) || + roles.contains(ModListModel::kIsActiveRole)) + updateVisibleCounts(); + int first = rowCount(), last = -1; + for (int row = top.row(); row <= bottom.row(); ++row) { + const auto* item = m_source->getItem(row); + if (!item) continue; + const int proxyRow = proxyRowForUuid(item->uuid); + if (proxyRow < 0) continue; + first = std::min(first, proxyRow); + last = std::max(last, proxyRow); + } + if (last >= 0) emit dataChanged(index(first), index(last), roles); +} +} // namespace px::modrim diff --git a/src/ui/models/mod_list_proxy_model.hpp b/src/ui/models/mod_list_proxy_model.hpp new file mode 100644 index 0000000..8f8ce48 --- /dev/null +++ b/src/ui/models/mod_list_proxy_model.hpp @@ -0,0 +1,51 @@ +#pragma once +#include +#include + +#include "ui/models/mod_list_model.hpp" +namespace px::modrim { +class ModListProxyModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(bool isFiltered READ isFiltered NOTIFY isFilteredChanged) + Q_PROPERTY(int activeCount READ activeCount NOTIFY activeCountChanged) + public: + explicit ModListProxyModel(ModListModel* source, QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + bool setData(const QModelIndex& index, const QVariant& value, + int role = Qt::EditRole) override; + Qt::ItemFlags flags(const QModelIndex& index) const override; + QHash roleNames() const override; + QModelIndex mapToSource(const QModelIndex& index) const; + QModelIndex mapFromSource(const QModelIndex& index) const; + int proxyRowForUuid(const QString& uuid) const; + void setSearchFilter(const QString& text); + void setSidebarFilter(std::optional> uuids); + bool isFiltered() const { + return !m_searchText.isEmpty() || m_sidebarUuids.has_value(); + } + int activeCount() const { return m_visibleActiveCount; } + bool moveRow(int source, int target, bool clampToActive = false); + signals: + void isFilteredChanged(); + void activeCountChanged(); + + private: + void rebuild(); + void rebuildSourceMapping(); + void layoutRebuild(); + void updateVisibleCounts(); + bool acceptItem(const ModItem& item) const; + void sourceDataChanged(const QModelIndex& top, const QModelIndex& bottom, + const QList& roles); + ModListModel* m_source; + QString m_searchText; + std::optional> m_sidebarUuids; + QStringList m_visibleUuids; + QHash m_sourceRows, m_proxyRows; + bool m_inProxyMove = false; + int m_visibleActiveCount = 0; + int m_visibleInactiveCount = 0; +}; +} // namespace px::modrim diff --git a/src/ui/models/sidebar_model.cpp b/src/ui/models/sidebar_model.cpp new file mode 100644 index 0000000..efd8d3f --- /dev/null +++ b/src/ui/models/sidebar_model.cpp @@ -0,0 +1,115 @@ +#include "ui/models/sidebar_model.hpp" + +#include "ui/theme/palette.hpp" +namespace px::modrim { +SidebarModel::SidebarModel(QObject* parent) : QAbstractListModel(parent) {} +int SidebarModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_items.size()); +} +QVariant SidebarModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto& item = m_items[index.row()]; + switch (role) { + case Qt::DisplayRole: + case kLabelRole: + return item.entry.label; + case kCountRole: + return item.entry.count(); + case kBadgeBgRole: + return item.badgeBg; + case kBadgeFgRole: + return item.badgeFg; + case kIconRole: + return item.iconName; + case kIconColorRole: + return item.iconColor; + case kSectionRole: + return item.sectionName; + default: + return {}; + } +} +QHash SidebarModel::roleNames() const { + return {{kLabelRole, "filterLabel"}, {kCountRole, "count"}, + {kBadgeBgRole, "badgeBg"}, {kBadgeFgRole, "badgeFg"}, + {kIconRole, "iconName"}, {kIconColorRole, "iconColor"}, + {kSectionRole, "sectionName"}}; +} +SidebarItem SidebarModel::makeItem(const view::SidebarEntry& entry) { + using Kind = view::SidebarEntry::Kind; + SidebarItem item{entry, + paletteValue(u"ELEVATE_4"), + paletteValue(u"TEXT_MUTED"), + QStringLiteral("grid"), + paletteValue(u"TEXT_DIM"), + QStringLiteral("Status")}; + switch (entry.kind) { + case Kind::kAll: + break; + case Kind::kActive: + item.iconName = QStringLiteral("check-circle"); + break; + case Kind::kInactive: + item.iconName = QStringLiteral("ban"); + break; + case Kind::kWithErrors: + item.badgeBg = paletteValue(u"DANGER_BG_QML"); + item.badgeFg = item.iconColor = paletteValue(u"DANGER"); + item.iconName = QStringLiteral("error"); + break; + case Kind::kWithWarnings: + item.badgeBg = paletteValue(u"WARNING_BG_QML"); + item.badgeFg = item.iconColor = paletteValue(u"WARNING"); + item.iconName = QStringLiteral("warning"); + break; + case Kind::kProvider: { + item.sectionName = QStringLiteral("Providers"); + item.iconName = QStringLiteral("folder"); + const auto label = entry.label.toLower(); + if (label.contains("steam") || label.contains("downloaded")) + item.iconName = QStringLiteral("steam"); + else if (label.contains("local")) + item.iconName = QStringLiteral("folder"); + else if (label.contains("core")) + item.iconName = QStringLiteral("grid"); + else if (label.contains("git")) + item.iconName = QStringLiteral("git"); + break; + } + } + return item; +} +void SidebarModel::setEntries(const QList& entries) { + beginResetModel(); + m_items.clear(); + m_items.reserve(entries.size()); + for (const auto& entry : entries) m_items.append(makeItem(entry)); + endResetModel(); +} +void SidebarModel::updateEntries(const QList& entries) { + if (entries.size() != m_items.size()) { + setEntries(entries); + return; + } + QSet changed; + for (int row = 0; row < entries.size(); ++row) { + auto next = makeItem(entries[row]); + const auto& old = m_items[row]; + if (old.entry.label != next.entry.label) changed.insert(kLabelRole); + if (old.entry.count() != next.entry.count()) changed.insert(kCountRole); + if (old.badgeBg != next.badgeBg) changed.insert(kBadgeBgRole); + if (old.badgeFg != next.badgeFg) changed.insert(kBadgeFgRole); + if (old.iconName != next.iconName) changed.insert(kIconRole); + if (old.iconColor != next.iconColor) changed.insert(kIconColorRole); + if (old.sectionName != next.sectionName) changed.insert(kSectionRole); + m_items[row] = std::move(next); + } + if (!changed.isEmpty() && !m_items.isEmpty()) + emit dataChanged(index(0), index(rowCount() - 1), changed.values()); +} +std::optional SidebarModel::getEntry(int row) const { + if (row < 0 || row >= m_items.size()) return std::nullopt; + return m_items[row].entry; +} +} // namespace px::modrim diff --git a/src/ui/models/sidebar_model.hpp b/src/ui/models/sidebar_model.hpp new file mode 100644 index 0000000..b0dfbeb --- /dev/null +++ b/src/ui/models/sidebar_model.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include + +#include "core/models/view/sidebar.hpp" +namespace px::modrim { +struct SidebarItem { + view::SidebarEntry entry; + QString badgeBg, badgeFg, iconName, iconColor, sectionName; +}; +class SidebarModel : public QAbstractListModel { + Q_OBJECT + public: + enum Role { + kLabelRole = Qt::UserRole + 1, + kCountRole, + kBadgeBgRole, + kBadgeFgRole, + kIconRole, + kIconColorRole, + kSectionRole + }; + Q_ENUM(Role) + explicit SidebarModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void setEntries(const QList& entries); + void updateEntries(const QList& entries); + std::optional getEntry(int row) const; + + private: + static SidebarItem makeItem(const view::SidebarEntry& entry); + QList m_items; +}; +} // namespace px::modrim diff --git a/src/ui/navigation.cpp b/src/ui/navigation.cpp new file mode 100644 index 0000000..e666ba8 --- /dev/null +++ b/src/ui/navigation.cpp @@ -0,0 +1,40 @@ +#include "ui/navigation.hpp" + +#include +#include + +namespace px::modrim { + +QString Route::url() const { return buildRoute(viewId, path); } + +QString buildRoute(const QString& viewId, const QStringList& path) { + QString url = QStringLiteral("%1://%2").arg(QString(kRouteScheme), viewId); + for (const QString& part : path) { + // An empty exclusion set encodes everything but the RFC 3986 unreserved + // characters. + url += QLatin1Char('/') + + QString::fromLatin1(part.toUtf8().toPercentEncoding()); + } + return url; +} + +std::optional parseRoute(const QString& url) { + const QUrl parsed(url, QUrl::StrictMode); + if (!parsed.isValid() || parsed.scheme() != kRouteScheme || + parsed.authority().isEmpty()) { + return std::nullopt; + } + + // Split the encoded path first, then decode each part, so an encoded '/' + // stays inside its segment. + Route route{parsed.authority(), {}}; + const QStringList encodedParts = + parsed.path(QUrl::FullyEncoded) + .split(QLatin1Char('/'), Qt::SkipEmptyParts); + for (const QString& part : encodedParts) { + route.path.append(QUrl::fromPercentEncoding(part.toUtf8())); + } + return route; +} + +} // namespace px::modrim diff --git a/src/ui/navigation.hpp b/src/ui/navigation.hpp new file mode 100644 index 0000000..2e70c53 --- /dev/null +++ b/src/ui/navigation.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::modrim { + +// modrim://[/…] links that open a view and optionally a +// sub-route inside it. +inline constexpr QLatin1StringView kRouteScheme{"modrim"}; +inline constexpr QLatin1StringView kSettingsViewId{"settings"}; + +struct Route { + QString viewId; + QStringList path; + + QString url() const; + + bool operator==(const Route&) const = default; +}; + +// Each path part is percent-encoded with nothing left unescaped, so ids +// containing +// '/' or ':' round-trip. +QString buildRoute(const QString& viewId, const QStringList& path = {}); + +// Parses `modrim://[/…]`; anything else is not a route. +std::optional parseRoute(const QString& url); + +} // namespace px::modrim diff --git a/src/ui/panels/about_panel.cpp b/src/ui/panels/about_panel.cpp new file mode 100644 index 0000000..3c728d2 --- /dev/null +++ b/src/ui/panels/about_panel.cpp @@ -0,0 +1,276 @@ +#include "ui/panels/about_panel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/version.hpp" +#include "ui/components/dialog_chrome.hpp" +#include "ui/context.hpp" +#include "ui/panels/upload_report_dialog.hpp" + +namespace px::modrim { +QStringList AboutPanel::environment() { +#if defined(__clang__) + const QString compiler = QStringLiteral("Clang " __clang_version__); +#elif defined(__GNUC__) + const QString compiler = QStringLiteral("GCC " __VERSION__); +#elif defined(_MSC_VER) + const QString compiler = QStringLiteral("MSVC %1").arg(_MSC_VER); +#else + const QString compiler = QStringLiteral("C++23"); +#endif + return {QStringLiteral("C++23 (%1)").arg(compiler), + QStringLiteral("Qt %1").arg(qVersion()), + QStringLiteral("%1 %2 (%3)") + .arg(QSysInfo::kernelType(), QSysInfo::kernelVersion(), + QSysInfo::currentCpuArchitecture())}; +} + +QString AboutPanel::systemInformation() const { + return (QStringList{QStringLiteral("PxModRim %1").arg(appVersion())} + + environment()) + .join(u'\n'); +} + +QString AboutPanel::creditsHtml() { + return QStringLiteral( + "

    PxModRim

    Created and maintained by PyXiion. " + "Licensed under GPL-3.0-or-later.

    " + "

    Inspiration

    RimSort

    " + "

    C++ dependencies

  • " + "" + "" + "" + "" + "" + "" + "" + "
    LibraryVersionLicense
    Qt%1LGPL-3.0 / " + "GPL-3.0 / commercial
    QCoroBuild-linkedMIT
    pugixml%2.%3.%4MIT
    libzip%5BSD-3-Clause
    spdlog%6.%7.%8MIT
    Glaze5.5.4 (build dependency)MIT
    ") + .arg(QString::fromLatin1(qVersion())) + .arg(PUGIXML_VERSION / 1000) + .arg(PUGIXML_VERSION / 10 % 100) + .arg(PUGIXML_VERSION % 10) + .arg(QString::fromLatin1(zip_libzip_version())) + .arg(SPDLOG_VER_MAJOR) + .arg(SPDLOG_VER_MINOR) + .arg(SPDLOG_VER_PATCH); +} + +AboutPanel::AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast) + : QDialog(parent), m_ctx(ctx), m_toast(toast) { + setObjectName(QStringLiteral("aboutPanel")); + setWindowTitle(tr("About PxModRim")); + setModal(true); + resize(700, 740); + setMinimumSize(640, 620); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(24, 24, 24, 20); + layout->setSpacing(18); + auto* hero = new QFrame(this); + hero->setObjectName(QStringLiteral("aboutHero")); + auto* heroLayout = new QHBoxLayout(hero); + heroLayout->setContentsMargins(20, 20, 20, 20); + heroLayout->setSpacing(20); + auto* logo = new QLabel(hero); + logo->setPixmap( + QPixmap(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg")) + .scaled(96, 96, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + heroLayout->addWidget(logo, 0, Qt::AlignTop); + auto* identity = new QVBoxLayout; + identity->setSpacing(6); + auto* titleRow = new QHBoxLayout; + auto* title = new QLabel(QStringLiteral("PxModRim"), hero); + title->setObjectName(QStringLiteral("aboutTitle")); + titleRow->addWidget(title); + titleRow->addStretch(); + auto* version = new QLabel(QStringLiteral("v%1").arg(appVersion()), hero); + version->setObjectName(QStringLiteral("aboutVersionBadge")); + titleRow->addWidget(version, 0, Qt::AlignVCenter); + identity->addLayout(titleRow); + auto* tagline = + new QLabel(tr("A friendly, modern mod manager for RimWorld."), hero); + tagline->setObjectName(QStringLiteral("aboutTagline")); + identity->addWidget(tagline); + auto* description = new QLabel(tr("Scan, sort, resolve dependencies, and " + "manage large mod lists easily."), + hero); + description->setObjectName(QStringLiteral("aboutDescription")); + description->setWordWrap(true); + identity->addWidget(description); + identity->addStretch(); + heroLayout->addLayout(identity, 1); + layout->addWidget(hero); + + auto* section = new QWidget(this); + auto* sectionLayout = new QVBoxLayout(section); + sectionLayout->setContentsMargins(0, 0, 0, 0); + sectionLayout->setSpacing(6); + auto* heading = new QLabel(tr("THE PROJECT"), section); + heading->setObjectName(QStringLiteral("aboutSectionTitle")); + sectionLayout->addWidget(heading); + auto* story = new QLabel( + tr("I used RimPy and RimSort for a long time myself, but with recent " + "updates it kept getting " + "slower and worse — some features even broke my game 🙁

    " + "I initially started working on a PR to fix most of the parallelism " + "issues in RimSort, " + "but realized it didn't make much sense. So I decided to try creating " + "my own alternative " + "with the help of AI. The result is what you see on your " + "screen.

    " + "This is all just my personal opinion based on my experience with " + "RimSort at the time, " + "and things may have changed for the better since then (I hope " + "so!).

    — PyXiion"), + section); + story->setObjectName(QStringLiteral("aboutStory")); + story->setWordWrap(true); + story->setAlignment(Qt::AlignTop | Qt::AlignLeft); + auto* scroll = new QScrollArea(section); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setWidget(story); + scroll->viewport()->setAutoFillBackground(false); + scroll->viewport()->setBackgroundRole(QPalette::NoRole); + story->setAutoFillBackground(false); + story->setBackgroundRole(QPalette::NoRole); + sectionLayout->addWidget(scroll, 1); + layout->addWidget(section, 1); + + auto* details = new QFrame(this); + details->setObjectName(QStringLiteral("aboutDetails")); + auto* grid = new QGridLayout(details); + grid->setContentsMargins(16, 14, 16, 14); + grid->setHorizontalSpacing(14); + grid->setVerticalSpacing(8); + const QList> rows{ + {tr("CREATED BY"), QStringLiteral("PyXiion")}, + {tr("LICENSE"), QStringLiteral("GPL-3.0-or-later")}, + {tr("INSPIRED BY"), QStringLiteral("RimSort")}, + {tr("ENVIRONMENT"), environment().join(QStringLiteral(" • "))}}; + for (qsizetype row = 0; row < rows.size(); ++row) { + auto* label = new QLabel(rows[row].first, details); + label->setObjectName(QStringLiteral("aboutMetaLabel")); + auto* value = new QLabel(rows[row].second, details); + value->setObjectName(QStringLiteral("aboutMetaValue")); + value->setWordWrap(true); + grid->addWidget(label, static_cast(row), 0); + grid->addWidget(value, static_cast(row), 1); + } + grid->setColumnStretch(1, 1); + layout->addWidget(details); + + auto* credits = new QDialog(this); + credits->setObjectName(QStringLiteral("creditsDialog")); + credits->setWindowTitle(tr("Open-Source Credits")); + credits->setModal(true); + credits->resize(560, 500); + auto* creditsLayout = new QVBoxLayout(credits); + creditsLayout->setContentsMargins(20, 20, 20, 16); + creditsLayout->setSpacing(14); + auto* creditsTitle = new QLabel(tr("Open-source credits"), credits); + creditsTitle->setObjectName(QStringLiteral("creditsTitle")); + creditsLayout->addWidget(creditsTitle); + auto* browser = new QTextBrowser(credits); + browser->setObjectName(QStringLiteral("creditsBrowser")); + browser->setOpenExternalLinks(true); + browser->setHtml(creditsHtml()); + creditsLayout->addWidget(browser); + auto* creditsFooter = new QHBoxLayout; + creditsFooter->addStretch(); + auto* creditsClose = new QPushButton(tr("Close"), credits); + creditsClose->setObjectName(QStringLiteral("appBtn")); + connect(creditsClose, &QPushButton::clicked, credits, &QDialog::reject); + creditsFooter->addWidget(creditsClose); + creditsLayout->addLayout(creditsFooter); + installDialogChrome(credits); + + auto* actions = new QHBoxLayout; + actions->setSpacing(8); + auto* github = new QPushButton(tr("GitHub"), this); + github->setObjectName(QStringLiteral("appBtn")); + connect(github, &QPushButton::clicked, this, [] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim"))); + }); + actions->addWidget(github); + auto* issues = new QPushButton(tr("Report issue"), this); + issues->setObjectName(QStringLiteral("appBtn")); + connect(issues, &QPushButton::clicked, this, [] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); + }); + actions->addWidget(issues); + auto* copy = new QPushButton(tr("Copy system info"), this); + copy->setObjectName(QStringLiteral("appBtn")); + connect(copy, &QPushButton::clicked, this, [this] { + QApplication::clipboard()->setText(systemInformation()); + m_toast.success(tr("System info copied")); + }); + actions->addWidget(copy); + m_upload = new QPushButton(tr("Upload log && system info…"), this); + m_upload->setObjectName(QStringLiteral("appBtn")); + connect(m_upload, &QPushButton::clicked, this, [this] { uploadReport(); }); + actions->addWidget(m_upload); + auto* showCredits = new QPushButton(tr("Open-source credits"), this); + showCredits->setObjectName(QStringLiteral("appBtn")); + connect(showCredits, &QPushButton::clicked, credits, &QDialog::open); + actions->addWidget(showCredits); + actions->addStretch(); + layout->addLayout(actions); + auto* disclaimer = + new QLabel(tr("PxModRim is an unofficial fan-made tool. RimWorld is a " + "trademark of Ludeon Studios."), + this); + disclaimer->setObjectName(QStringLiteral("aboutDisclaimer")); + disclaimer->setWordWrap(true); + layout->addWidget(disclaimer); + auto* footer = new QHBoxLayout; + footer->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("primaryAction")); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + footer->addWidget(close); + layout->addLayout(footer); +} + +QCoro::Task AboutPanel::uploadReport() { + QPointer button(m_upload); + m_upload->setEnabled(false); + co_await handleUploadReport(this, m_ctx, m_toast); + if (button) button->setEnabled(true); +} +} // namespace px::modrim diff --git a/src/ui/panels/about_panel.hpp b/src/ui/panels/about_panel.hpp new file mode 100644 index 0000000..84bb37c --- /dev/null +++ b/src/ui/panels/about_panel.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +#include + +class QPushButton; +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +class Notifier; +class AboutPanel : public QDialog { + public: + AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast); + static QStringList environment(); + QString systemInformation() const; + static QString creditsHtml(); + + private: + QCoro::Task uploadReport(); + CoreContext& m_ctx; + Notifier& m_toast; + QPushButton* m_upload; +}; +} // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.cpp b/src/ui/panels/keyboard_shortcuts_dialog.cpp new file mode 100644 index 0000000..32a23ac --- /dev/null +++ b/src/ui/panels/keyboard_shortcuts_dialog.cpp @@ -0,0 +1,40 @@ +#include "ui/panels/keyboard_shortcuts_dialog.hpp" + +#include +#include +#include +#include +#include + +namespace px::modrim { +KeyboardShortcutsDialog::KeyboardShortcutsDialog( + const QList>& rows, QWidget* parent) + : QDialog(parent) { + setWindowTitle(tr("Keyboard Shortcuts")); + resize(520, 600); + auto* layout = new QVBoxLayout(this); + auto* table = new QTableWidget(static_cast(rows.size()), 2, this); + table->setHorizontalHeaderLabels({tr("Shortcut"), tr("Action")}); + table->verticalHeader()->hide(); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSelectionMode(QAbstractItemView::NoSelection); + table->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch); + for (qsizetype row = 0; row < rows.size(); ++row) { + table->setItem(static_cast(row), 0, + new QTableWidgetItem(rows[row].first)); + table->setItem(static_cast(row), 1, + new QTableWidgetItem(rows[row].second)); + } + layout->addWidget(table); + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("primaryAction")); + close->setDefault(true); + connect(close, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(close); + layout->addLayout(buttons); +} +} // namespace px::modrim diff --git a/src/ui/panels/keyboard_shortcuts_dialog.hpp b/src/ui/panels/keyboard_shortcuts_dialog.hpp new file mode 100644 index 0000000..f346ac2 --- /dev/null +++ b/src/ui/panels/keyboard_shortcuts_dialog.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::modrim { +inline const QList> kQmlShortcuts{ + {QStringLiteral("Up / Down"), QStringLiteral("Navigate mod list")}, + {QStringLiteral("Shift+Up / Shift+Down"), + QStringLiteral("Extend mod selection")}, + {QStringLiteral("Return / Space"), QStringLiteral("Toggle selected mods")}, + {QStringLiteral("Ctrl+A"), QStringLiteral("Select all mods")}, +}; + +class KeyboardShortcutsDialog : public QDialog { + public: + explicit KeyboardShortcutsDialog(const QList>& rows, + QWidget* parent = nullptr); +}; +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_data.cpp b/src/ui/panels/mod_info_data.cpp new file mode 100644 index 0000000..913d1c1 --- /dev/null +++ b/src/ui/panels/mod_info_data.cpp @@ -0,0 +1,262 @@ +#include "ui/panels/mod_info_data.hpp" + +#include +#include +#include +#include +#include +#include + +#include "core/downloads/manager.hpp" +#include "ui/components/unity_rich_text.hpp" +#include "ui/models/mod_list_model.hpp" + +namespace px::modrim { +namespace { +template +QString substitute(const QString& text, const QRegularExpression& re, + F replacement) { + QString out; + out.reserve(text.size()); + qsizetype end = 0; + auto matches = re.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + out += QStringView(text).sliced(end, match.capturedStart() - end); + out += replacement(match); + end = match.capturedEnd(); + } + out += QStringView(text).sliced(end); + return out; +} +QRegularExpression insensitive(const QString& pattern) { + return QRegularExpression(pattern, + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::DotMatchesEverythingOption); +} +} // namespace +QString bbcodeImages(QString text) { + static const auto re = + insensitive(R"(\[img\]((https?)://[^\s\[<"]+)\[/img\])"); + return substitute(text, re, [](const auto& match) { + const QString url = match.captured(1); + return match.captured(2).compare("https", Qt::CaseInsensitive) == 0 + ? "" + : "" + url + ""; + }); +} +QString restoreEntities(QString text) { + static const QRegularExpression re( + R"(&(#\d{1,7}|#x[0-9a-fA-F]{1,6}|[a-zA-Z]{2,8});)"); + return text.replace(re, "&\\1;"); +} +QString bbcodeMarkup(QString text) { + static const auto namedUrl = + insensitive(R"(\[url=(https?://[^\]\s"<]+)\](.*?)\[/url\])"); + static const auto bareUrl = + insensitive(R"(\[url\](https?://[^\[\s"<]+)\[/url\])"); + static const auto quote = insensitive(R"(\[quote=([^\]]+)\])"); + static const auto table = insensitive(R"(\[table[^\]]*\])"); + static const auto strip = insensitive(R"(\[/?(?:spoiler|noparse)\])"); + static const auto hr = insensitive(R"(\[hr\])"); + static const auto paired = insensitive( + R"(\[(/?)(b|i|u|s|strike|h1|h2|h3|code|quote|list|olist|table|tr|td|th)\])"); + static const QHash names{{"strike", "s"}, + {"code", "pre"}, + {"quote", "blockquote"}, + {"list", "ul"}, + {"olist", "ol"}}; + text.replace(namedUrl, "\\2"); + text.replace(bareUrl, "\\1"); + text.replace(quote, "
    \\1 wrote:
    "); + text.replace(table, ""); + text.replace(strip, ""); + text.replace(hr, "
    "); + text.replace("[*]", "
  • "); + text = substitute(text, paired, [](const auto& match) { + const QString tag = match.captured(2).toLower(); + return "<" + match.captured(1) + names.value(tag, tag) + ">"; + }); + static const QRegularExpression before( + R"((?:
    \s*)+(?=)(?:
    \s*)+)"); + text.replace(before, ""); + return text.replace(after, "\\1"); +} +QString autolink(const QString& text) { + static const QRegularExpression tags("<[^>]+>"); + static const QRegularExpression urls( + R"(https?://(?:(?!&(?:quot|lt|gt|#x27|#39);)[^\s<>"])+)"); + const auto linkText = [](const QString& part) { + return substitute(part, urls, [](const auto& match) { + const QString url = match.captured(); + QString stripped = url; + while (!stripped.isEmpty() && + QStringLiteral(".,;:!?)").contains(stripped.back())) + stripped.chop(1); + return "" + stripped + "" + + url.mid(stripped.size()); + }); + }; + QString out; + qsizetype end = 0; + bool anchor = false; + auto matches = tags.globalMatch(text); + while (matches.hasNext()) { + const auto match = matches.next(); + const QString plain = text.mid(end, match.capturedStart() - end); + out += anchor ? plain : linkText(plain); + const QString tag = match.captured(), lowered = tag.toLower(); + if (lowered.startsWith("") + anchor = true; + else if (lowered == "") + anchor = false; + out += tag; + end = match.capturedEnd(); + } + const QString tail = text.mid(end); + return out + (anchor ? tail : linkText(tail)); +} +QString descriptionToHtml(const QString& text, + const QString& descriptionFormat) { + if (descriptionFormat != "bbcode") + return escapeHtml(text).replace('\n', "
    "); + return autolink( + bbcodeMarkup(bbcodeImages(restoreEntities(unityRichTextToHtml(text))))); +} +QString formatAge(double mtime, std::optional now) { + if (mtime <= 0) return {}; + const double delta = + now.value_or(QDateTime::currentMSecsSinceEpoch() / 1000.0) - mtime; + static constexpr std::array, 5> kUnits{ + {{"year", 365 * 86400}, + {"month", 30 * 86400}, + {"week", 7 * 86400}, + {"day", 86400}, + {"hour", 3600}}}; + for (const auto& [name, seconds] : kUnits) + if (delta >= seconds) { + const auto count = static_cast(delta / seconds); + return QString::number(count) + " " + name + (count == 1 ? "" : "s") + + " ago"; + } + return "just now"; +} +QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, + const QList& issues) { + const auto* about = dynamic_cast(&mod); + const auto& active = ctx.activeUuids(); + const auto position = active.indexOf(mod.uuid()); + QVariantList issueRows, needs, neededBy, details; + QStringList conflicts; + int errors = 0; + for (const auto& issue : issues) { + errors += issue.isError; + issueRows.append(QVariantMap{{"title", issue.categoryDisplayName}, + {"detail", issue.detail.value_or(QString{})}, + {"isError", issue.isError}}); + } + const auto warnings = issues.size() - errors; + const QString level = errors ? "error" : warnings ? "warning" : "ok"; + const auto count = errors ? errors : warnings; + const QString title = count ? QString::number(count) + + (errors ? " error" : " warning") + + (count == 1 ? "" : "s") + : QStringLiteral("No issues"); + if (about) { + QHash index; + for (const auto& entry : ctx.allMods()) + if (const auto* other = dynamic_cast(entry.get())) + if (!index.contains(other->packageId.str())) + index.insert(other->packageId.str(), other); + for (const auto& dep : about->aboutRules.dependencies) { + auto* found = index.value(dep.packageId.str(), nullptr); + if (!found) + for (const auto& alternative : dep.alternativePackageIds) { + found = index.value(alternative.str(), nullptr); + if (found) break; + } + needs.append(QVariantMap{ + {"name", dep.name.isEmpty() ? dep.packageId.str() : dep.name}, + {"state", !found ? "missing" + : active.contains(found->uuid()) ? "active" + : "inactive"}, + {"uuid", found ? found->uuid() : QString{}}}); + } + for (const auto& pid : about->aboutRules.incompatibleWith) { + const auto* other = index.value(pid.str(), nullptr); + if (other && active.contains(other->uuid())) + conflicts.append(other->name); + } + std::sort( + conflicts.begin(), conflicts.end(), + [](const auto& a, const auto& b) { return a.toLower() < b.toLower(); }); + QList dependents; + for (const auto& entry : ctx.allMods()) { + const auto* other = dynamic_cast(entry.get()); + if (!other || other->uuid() == mod.uuid()) continue; + if (std::any_of( + other->aboutRules.dependencies.cbegin(), + other->aboutRules.dependencies.cend(), [about](const auto& dep) { + return dep.packageId == about->packageId || + dep.alternativePackageIds.contains(about->packageId); + })) + dependents.append(other); + } + std::sort(dependents.begin(), dependents.end(), [](auto* a, auto* b) { + return a->name.toLower() < b->name.toLower(); + }); + for (const auto* other : dependents) + neededBy.append( + QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); + } + auto& downloads = ctx.downloads(); + auto* source = downloads.sourceOf(mod); + const auto row = [&details](const QString& label, const QString& value, + bool copy = false) { + QVariantMap item{{"label", label}, {"value", value}}; + if (copy) item.insert("copy", true); + details.append(item); + }; + const QString authors = about ? about->authors.join(", ") : QString{}; + QString provider; + if (about) { + if (!about->authors.isEmpty()) row("Author", authors); + if (!about->modVersion.isEmpty()) row("Version", about->modVersion); + provider = providerLabel(mod.providerId, about->packageId.str()); + if (!provider.isEmpty()) row("Source", provider); + if (about->mtime > 0) row("Updated", formatAge(about->mtime)); + if (const auto synced = downloads.lastSynced(mod); synced && *synced != 0) + row("Last synced", formatAge(*synced)); + row("Package ID", about->packageId.str(), true); + } + if (mod.modPath()) row("Path", *mod.modPath(), true); + const QString target = ctx.targetVersion(); + const bool known = !mod.supportedVersions.isEmpty() && target != "Unknown"; + return {{"name", mod.name}, + {"author", authors}, + {"provider", provider}, + {"status", QVariantMap{{"level", level}, + {"title", title}, + {"active", position >= 0}, + {"position", position >= 0 ? position + 1 : 0}, + {"total", active.size()}, + {"issues", issueRows}, + {"obsolete", mod.obsolete}, + {"valid", mod.valid}}}, + {"version", + QVariantMap{{"known", known}, + {"ok", known && mod.supportedVersions.contains(target)}, + {"target", target}}}, + {"needs", needs}, + {"conflicts", conflicts}, + {"neededBy", neededBy}, + {"description", descriptionToHtml(mod.description)}, + {"details", details}, + {"canOpenFolder", mod.modPath() && QFileInfo::exists(*mod.modPath())}, + {"canUpdate", source != nullptr}, + {"updateSource", source ? source->label() : QString{}}, + {"url", about ? about->url : QString{}}}; +} +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_data.hpp b/src/ui/panels/mod_info_data.hpp new file mode 100644 index 0000000..59256e5 --- /dev/null +++ b/src/ui/panels/mod_info_data.hpp @@ -0,0 +1,19 @@ +#pragma once +#include +#include + +#include "core/context.hpp" +#include "core/models/view/diagnostics.hpp" +namespace px::modrim { +inline const QString kImgWidthToken = QStringLiteral("__IMG_WIDTH__"); +QString bbcodeImages(QString text); +QString restoreEntities(QString text); +QString bbcodeMarkup(QString text); +QString autolink(const QString& text); +QString descriptionToHtml( + const QString& text, + const QString& descriptionFormat = QStringLiteral("bbcode")); +QString formatAge(double mtime, std::optional now = std::nullopt); +QVariantMap buildModInfo(CoreContext& ctx, const ListedMod& mod, + const QList& issues); +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_panel.cpp b/src/ui/panels/mod_info_panel.cpp new file mode 100644 index 0000000..545d261 --- /dev/null +++ b/src/ui/panels/mod_info_panel.cpp @@ -0,0 +1,351 @@ +#include "ui/panels/mod_info_panel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/startup_impact_service/labels.hpp" +#include "ui/components/procedural_preview.hpp" +#include "ui/config.hpp" +#include "ui/models/impact.hpp" +#include "ui/panels/mod_info_data.hpp" +#include "ui/panels/time_analytics_panel.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +class ModInfoBanner final : public QWidget { + public: + explicit ModInfoBanner(QWidget* parent) : QWidget(parent) { + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + } + bool hasHeightForWidth() const override { return true; } + int heightForWidth(int width) const override { + return std::min(width * kBannerAspectH / kBannerAspectW, kBannerMaxHeight); + } + QSize sizeHint() const override { + const int w = width() ? width() : 300; + return {w, heightForWidth(w)}; + } + void setTitle(QString title, QString subtitle) { + m_title = std::move(title); + m_subtitle = std::move(subtitle); + update(); + } + void setImage(QImage image, bool overlay) { + m_image = std::move(image); + m_overlay = overlay; + rescale(); + update(); + } + + protected: + void resizeEvent(QResizeEvent* event) override { + rescale(); + QWidget::resizeEvent(event); + } + void paintEvent(QPaintEvent*) override { + QPainter painter(this); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + painter.setClipRect(rect()); + if (m_scaled.isNull()) + painter.fillRect(rect(), panelBgQ()); + else + painter.drawImage((width() - m_scaled.width()) / 2, + (height() - m_scaled.height()) / 2, m_scaled); + if (m_overlay && !m_title.isEmpty()) { + QLinearGradient gradient(0, height() - kBannerOverlayHeight, 0, height()); + gradient.setColorAt(0, QColor(0, 0, 0, 0)); + gradient.setColorAt(1, QColor(0, 0, 0, 180)); + painter.fillRect(0, height() - kBannerOverlayHeight, width(), + kBannerOverlayHeight, gradient); + const int top = height() - kBannerOverlayHeight + 12; + int titleHeight = kBannerOverlayHeight - 24; + if (!m_subtitle.isEmpty()) titleHeight = titleHeight * 3 / 5; + auto font = painter.font(); + font.setPointSize(18); + font.setBold(true); + painter.setFont(font); + painter.setPen(textMainQ()); + painter.drawText(QRect(16, top, width() - 32, titleHeight), + Qt::AlignLeft | Qt::AlignBottom, m_title); + if (!m_subtitle.isEmpty()) { + font.setPointSize(12); + font.setBold(false); + painter.setFont(font); + painter.setPen(textMutedQ()); + painter.drawText(QRect(16, top + titleHeight + 2, width() - 32, + kBannerOverlayHeight - titleHeight - 26), + Qt::AlignLeft | Qt::AlignTop, m_subtitle); + } + } + } + + private: + void rescale() { + m_scaled = m_image.isNull() || width() <= 0 + ? QImage{} + : m_image.scaled(width(), heightForWidth(width()), + Qt::KeepAspectRatioByExpanding, + Qt::SmoothTransformation); + } + QImage m_image, m_scaled; + QString m_title, m_subtitle; + bool m_overlay = true; +}; +namespace { +QString firstSentence(QString text) { + static const QRegularExpression markup( + R"(\[img\].*?\[/img\]|<[^>]+>|\[/?\w+(?:=[^\]]*)?\]|\[\*\])", + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::DotMatchesEverythingOption); + text.replace(markup, " "); + text = QTextDocumentFragment::fromHtml(text).toPlainText().simplified(); + const auto end = text.indexOf('.'); + if (end >= 0) text.truncate(end + 1); + if (text.size() > 80) { + text.truncate(80); + const auto space = text.lastIndexOf(' '); + if (space >= 0) text.truncate(space); + text += QChar(0x2026); + } + return text; +} +} // namespace +ModInfoPanel::ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, + QWidget* parent) + : QWidget(parent), + m_ctx(ctx), + m_engine(engine), + m_prefs(prefs), + m_banner(new ModInfoBanner(this)), + m_placeholder(new QLabel("Select a mod to view details", this)), + m_qml(engine ? new QQuickWidget(engine, this) : new QQuickWidget(this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + m_banner->hide(); + layout->addWidget(m_banner, 0, Qt::AlignTop); + m_placeholder->setAlignment(Qt::AlignCenter); + m_placeholder->setObjectName("placeholder"); + layout->addWidget(m_placeholder, 0, Qt::AlignTop); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(u"ELEVATE_2")); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModInfo.qml"))); + m_qml->hide(); + layout->addWidget(m_qml, 1); + if (auto* root = m_qml->rootObject()) { + root->setProperty("descExpanded", m_prefs.desc_expanded); + connect(root, SIGNAL(openFolder()), this, SLOT(openFolder())); + connect(root, SIGNAL(openUrl()), this, SLOT(openUrl())); + connect(root, SIGNAL(selectMod(QString)), this, + SIGNAL(modRequested(QString))); + connect(root, SIGNAL(copyText(QString)), this, SLOT(copyText(QString))); + connect(root, SIGNAL(openStartupDetails()), this, + SLOT(openStartupDetails())); + connect(root, SIGNAL(descToggled(bool)), this, SLOT(descToggled(bool))); + connect(root, SIGNAL(updateMods()), this, SLOT(updateMod())); + } + setQml("downloadsBusy", m_ctx.downloads().isDownloading()); + m_busyConnection = m_ctx.downloads().busyChanged.connect([this](bool busy) { + setQml("downloadsBusy", busy); + syncUpdating(); + }); +} +ModInfoPanel::~ModInfoPanel() { + m_ctx.downloads().busyChanged.disconnect(m_busyConnection); +} +void ModInfoPanel::setQml(const char* name, const QVariant& value) { + if (auto* root = m_qml->rootObject()) root->setProperty(name, value); +} +void ModInfoPanel::showMod(const ListedMod& mod, + const QList& issues) { + const auto token = ++m_previewToken; + m_mod = m_ctx.allMods().value(mod.uuid()); + if (!m_mod) { + if (const auto* about = dynamic_cast(&mod)) + m_mod = std::make_shared(*about); + else + m_mod = std::make_shared(mod); + } + m_placeholder->hide(); + m_banner->show(); + m_qml->show(); + const auto* about = dynamic_cast(&mod); + m_banner->setTitle(mod.name, !mod.description.isEmpty() + ? firstSentence(mod.description) + : about ? about->packageId.str() + : QString{}); + ++m_startupToken; + setQml("startup", QVariant{}); + setIssues(issues); + syncUpdating(); + if (auto* root = m_qml->rootObject()) + QMetaObject::invokeMethod(root, "resetScroll"); + loadPreview(mod.modPath(), mod.name, token); +} +void ModInfoPanel::setIssues(const QList& issues) { + if (!m_mod) return; + auto data = buildModInfo(m_ctx, *m_mod, issues); + const QString description = data.take("description").toString(); + setQml("info", data); + if (description != m_description) { + m_description = description; + setQml("description", description); + } +} +void ModInfoPanel::clear() { + m_mod.reset(); + ++m_previewToken; + ++m_startupToken; + m_placeholder->show(); + m_banner->hide(); + m_qml->hide(); + m_description.clear(); + m_startupPid.reset(); + m_startupActivePids.clear(); + setQml("info", QVariant{}); + setQml("description", QString{}); + setQml("startup", QVariant{}); +} +void ModInfoPanel::syncUpdating() { + const auto id = m_mod ? m_ctx.downloads().updatableId(*m_mod) : std::nullopt; + setQml("updatingThis", id && m_ctx.downloads().activeIds().contains(*id)); +} +void ModInfoPanel::openFolder() { + if (m_mod && m_mod->modPath()) + QDesktopServices::openUrl(QUrl::fromLocalFile(*m_mod->modPath())); +} +void ModInfoPanel::openUrl() { + const auto* about = dynamic_cast(m_mod.get()); + if (!about) return; + const QUrl url(about->url); + const auto scheme = url.scheme().toLower(); + if (scheme == "http" || scheme == "https") QDesktopServices::openUrl(url); +} +void ModInfoPanel::copyText(const QString& text) { + QGuiApplication::clipboard()->setText(text); +} +void ModInfoPanel::descToggled(bool expanded) { + m_prefs.desc_expanded = expanded; + if (m_ctx.hasConfigService()) saveUiPrefs(m_prefs, m_ctx.configService()); +} +void ModInfoPanel::updateMod() { + if (!m_mod) return; + if (auto id = m_ctx.downloads().updatableId(*m_mod)) performUpdate(*id); +} +QCoro::Task ModInfoPanel::performUpdate(QString id) { + const QStringList ids{id}; + const auto result = co_await m_ctx.downloads().downloadMods(ids); + if (!result) + qWarning().noquote() << "Mod update not started:" << result.error().message; +} +QCoro::Task ModInfoPanel::loadPreview(std::optional path, + QString title, quint64 token) { + QPointer guard(this); + const int width = m_banner->width() ? m_banner->width() : 300; + const int height = m_banner->heightForWidth(width); + auto future = QtConcurrent::run( + [path = std::move(path), title = std::move(title), width, height] { + QImage image; + if (path) image.load(*path + "/About/Preview.png"); + const bool overlay = image.isNull(); + if (overlay) image = generatePreview(title, width, height); + return std::pair{image, overlay}; + }); + auto [image, overlay] = co_await future; + if (guard && token == guard->m_previewToken) + guard->m_banner->setImage(std::move(image), overlay); +} +QCoro::Task ModInfoPanel::setTimeAnalytics( + const std::optional& pid, const QStringList& activePids) { + const auto token = ++m_startupToken; + m_startupPid = pid; + m_startupActivePids = activePids; + if (!pid) { + setQml("startup", + QVariantMap{{"available", false}, + {"message", "Startup impact is unavailable."}}); + co_return; + } + QPointer guard(this); + const auto selectedPid = *pid; + const auto active = activePids; + auto result = + co_await m_ctx.modService().startupImpact().snapshot(active, selectedPid); + if (!guard || token != guard->m_startupToken) co_return; + if (!result || !result->report) { + guard->setQml( + "startup", + QVariantMap{{"available", false}, + {"message", + result ? QStringLiteral("No startup data recorded yet. " + "Launch the game to generate it.") + : result.error().message}}); + co_return; + } + const auto& report = *result->report; + const auto* mod = report.find(selectedPid, std::nullopt); + const double own = result->selectedAvg; + if (!mod || own < .001) { + guard->setQml( + "startup", + QVariantMap{{"available", false}, + {"message", + "This mod wasn't measured in the last recorded launch."}}); + co_return; + } + int rank = 1; + for (const auto& entry : report.mods) rank += entry.totalImpactS > own; + QList> metrics; + for (auto it = mod->metrics.cbegin(); it != mod->metrics.cend(); ++it) + metrics.append({it.key(), it.value()}); + std::stable_sort( + metrics.begin(), metrics.end(), + [](const auto& a, const auto& b) { return a.second > b.second; }); + QVariantList rows; + for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) + if (metrics[i].second >= .001) + rows.append( + QVariantMap{{"label", startup_impact::metricLabel(metrics[i].first)}, + {"value", formatDuration(metrics[i].second)}, + {"fraction", std::min(1.0, metrics[i].second / own)}}); + guard->setQml( + "startup", + QVariantMap{ + {"available", true}, + {"own", formatDuration(own)}, + {"rank", + QString("#%1 slowest of %2 mods").arg(rank).arg(report.mods.size())}, + {"estimated", !active.contains(selectedPid, Qt::CaseInsensitive)}, + {"color", impactColor(own)}, + {"rows", rows}}); +} +void ModInfoPanel::openStartupDetails() { showStartupDetails(); } +QCoro::Task ModInfoPanel::showStartupDetails() { + if (!m_startupDialog) + m_startupDialog = new StartupImpactDialog( + &m_ctx.modService().startupImpact(), m_engine, this); + m_startupDialog->show(); + m_startupDialog->raise(); + m_startupDialog->activateWindow(); + co_await m_startupDialog->panel->setData(m_startupPid, m_startupActivePids); +} +} // namespace px::modrim diff --git a/src/ui/panels/mod_info_panel.hpp b/src/ui/panels/mod_info_panel.hpp new file mode 100644 index 0000000..1c9c5cb --- /dev/null +++ b/src/ui/panels/mod_info_panel.hpp @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include + +#include "ui/mod_selection.hpp" +#include "ui/ui_prefs.hpp" +class QQmlEngine; +class QQuickWidget; +class QLabel; +namespace px::modrim { +class ModInfoBanner; +class StartupImpactDialog; +class ModInfoPanel : public QWidget, public ModInfoSink { + Q_OBJECT + public: + ModInfoPanel(CoreContext& ctx, QQmlEngine* engine, UiPrefs& prefs, + QWidget* parent = nullptr); + ~ModInfoPanel() override; + void showMod(const ListedMod& mod, + const QList& issues) override; + void setIssues(const QList& issues); + void clear() override; + QCoro::Task setTimeAnalytics(const std::optional& pid, + const QStringList& activePids) override; + signals: + void modRequested(const QString& uuid); + private slots: + void openFolder(); + void openUrl(); + void copyText(const QString& text); + void descToggled(bool expanded); + void updateMod(); + void openStartupDetails(); + + private: + void setQml(const char* name, const QVariant& value); + void syncUpdating(); + QCoro::Task loadPreview(std::optional path, QString title, + quint64 token); + QCoro::Task performUpdate(QString id); + QCoro::Task showStartupDetails(); + CoreContext& m_ctx; + QQmlEngine* m_engine; + UiPrefs& m_prefs; + ListedModPtr m_mod; + ModInfoBanner* m_banner; + QLabel* m_placeholder; + QQuickWidget* m_qml; + QString m_description; + quint64 m_previewToken = 0; + quint64 m_startupToken = 0; + std::optional m_startupPid; + QStringList m_startupActivePids; + QPointer m_startupDialog; + Event::Id m_busyConnection; +}; +} // namespace px::modrim diff --git a/src/ui/panels/mod_list_panel.cpp b/src/ui/panels/mod_list_panel.cpp new file mode 100644 index 0000000..7559d81 --- /dev/null +++ b/src/ui/panels/mod_list_panel.cpp @@ -0,0 +1,237 @@ +#include "ui/panels/mod_list_panel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/context.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "ui/components/icons.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/theme/palette.hpp" +namespace px::modrim { +ModListPanel::ModListPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent) + : QWidget(parent), + m_ctx(ctx), + m_model(new ModListModel({}, &ctx.diagnosticsService(), this)), + m_proxy(new ModListProxyModel(m_model, this)), + m_qml(new QQuickWidget(engine, this)), + m_compactMode(ctx.config().compact_mod_list), + m_highlightTimer(new QTimer(this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + auto* search = new QWidget(this); + search->setObjectName(QStringLiteral("searchBox")); + search->setFixedHeight(56); + auto* searchLayout = new QHBoxLayout(search); + searchLayout->setContentsMargins(16, 8, 16, 8); + searchInput = new QLineEdit(search); + searchInput->setObjectName(QStringLiteral("searchInput")); + searchInput->setPlaceholderText( + QStringLiteral("Search mods, package ID, author…")); + searchInput->setClearButtonEnabled(true); + connect(searchInput, &QLineEdit::textChanged, this, + &ModListPanel::setSearchFilter); + if (const auto searchIcon = icon(u"search", 16, paletteValue(u"TEXT_DIM"))) + searchInput->addAction(*searchIcon, QLineEdit::LeadingPosition); + searchLayout->addWidget(searchInput); + layout->addWidget(search); + connect(m_model, &ModListModel::activeModsChanged, this, + &ModListPanel::activeModsChanged); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->setAcceptDrops(true); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListPanel"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListModel"), + m_proxy); + m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"), + false); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModList.qml"))); + layout->addWidget(m_qml); + m_qml->setFocusPolicy(Qt::StrongFocus); + m_qml->installEventFilter(this); + m_highlightTimer->setSingleShot(true); + m_highlightTimer->setInterval(1200); + connect(m_highlightTimer, &QTimer::timeout, this, + [this] { setHighlighted({}); }); + m_activeConnection = m_ctx.activeStateChanged().connect( + [this](const auto& active) { coreStateChanged(active); }); + m_configConnection = m_ctx.configChanged().connect( + [this](const auto& cfg) { setCompactMode(cfg.compact_mod_list); }); + m_modsConnection = m_ctx.modService().modsChanged.connect( + [this](const auto&) { refresh(); }); +} +ModListPanel::~ModListPanel() { + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.configChanged().disconnect(m_configConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); + // QML bindings refer to the models and panel during destruction. + delete m_qml; +} +void ModListPanel::loadMods(const QHash& mods, + const QStringList& active) { + m_model->loadMods(mods, active); +} +QStringList ModListPanel::activeUuids() const { return m_model->activeUuids(); } +void ModListPanel::setSidebarFilter(std::optional> uuids) { + m_proxy->setSidebarFilter(std::move(uuids)); +} +void ModListPanel::setSearchFilter(const QString& text) { + m_proxy->setSearchFilter(text); +} +void ModListPanel::setCompactMode(bool enabled) { + if (m_compactMode == enabled) return; + m_compactMode = enabled; + emit compactModeChanged(enabled); +} +bool ModListPanel::isHighlighted(const QString& uuid) const { + return m_highlighted.contains(uuid); +} +void ModListPanel::modSelected(const QString& uuid) { emit modPicked(uuid); } +int ModListPanel::proxyToSourceRow(int row) const { + return m_proxy->mapToSource(m_proxy->index(row)).row(); +} +QString ModListPanel::uuidAt(int row) const { + const auto* item = m_model->getItem(proxyToSourceRow(row)); + return item ? item->uuid : QString{}; +} +int ModListPanel::rowForUuid(const QString& uuid) const { + return m_proxy->proxyRowForUuid(uuid); +} +bool ModListPanel::isChecked(int row) const { + const auto* item = m_model->getItem(proxyToSourceRow(row)); + return item && item->checked; +} +bool ModListPanel::selectUuid(const QString& uuid) { + const int row = rowForUuid(uuid); + auto* root = m_qml->rootObject(); + if (row < 0 || !root) return false; + return QMetaObject::invokeMethod(root, "revealRow", Qt::DirectConnection, + Q_ARG(QVariant, QVariant(row)), + Q_ARG(QVariant, QVariant(uuid))); +} +void ModListPanel::clearSelection() { + auto* root = m_qml->rootObject(); + auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (view) { + view->setProperty("currentIndex", -1); + view->setProperty("anchorIndex", -1); + view->setProperty("selectedIndices", QVariantList{}); + view->setProperty("selectedUuids", QVariantList{}); + view->setProperty("currentUuid", QString{}); + view->setProperty("anchorUuid", QString{}); + } + emit selectionUpdated({}); + emit modPicked({}); +} +QCoro::Task ModListPanel::refresh() { + m_model->updateProviderColors(m_ctx.modService().providerColors()); + m_model->loadMods(m_ctx.allMods(), m_ctx.activeUuids()); + QPointer self(this); + const auto impacts = + co_await m_ctx.modService().startupImpact().getAllAverages(); + if (self && impacts) self->m_model->setStartupImpact(*impacts); +} +QCoro::QmlTask ModListPanel::toggleChecked(const QVariantList& rows) { + QList selected; + selected.reserve(rows.size()); + for (const auto& row : rows) selected.append(row.toInt()); + return toggleRows(std::move(selected)); +} +QCoro::Task ModListPanel::toggleRows(QList rows) { + QStringList selected; + for (const int row : rows) { + const auto uuid = uuidAt(row); + if (!uuid.isEmpty()) selected.append(uuid); + } + if (selected.isEmpty()) co_return; + const QSet previous(m_ctx.activeUuids().begin(), + m_ctx.activeUuids().end()); + QPointer self(this); + co_await toggleMods(m_ctx, this, selected); + if (!self) co_return; + QStringList newlyEnabled; + for (const auto& uuid : selected) + if (!previous.contains(uuid) && m_ctx.activeUuids().contains(uuid)) + newlyEnabled.append(uuid); + if (!newlyEnabled.isEmpty()) highlightRows(newlyEnabled); +} +void ModListPanel::setHighlighted(const QStringList& uuids) { + m_highlighted = QSet(uuids.begin(), uuids.end()); + ++m_highlightRevision; + emit highlightedChanged(); +} +void ModListPanel::highlightRows(const QStringList& uuids) { + int last = -1; + for (const auto& uuid : uuids) + if (const int row = rowForUuid(uuid); row >= 0) last = row; + auto* root = m_qml->rootObject(); + if (last < 0 || !root) return; + if (auto* view = root->findChild(QStringLiteral("listView"))) + QMetaObject::invokeMethod(view, "positionViewAtIndex", Qt::DirectConnection, + Q_ARG(int, last), Q_ARG(int, 4)); + setHighlighted(uuids); + m_highlightTimer->start(); +} +void ModListPanel::showContextMenu(const QVariantList& values) { + if (!m_ctx.downloads().available()) return; + QStringList uuids; + uuids.reserve(values.size()); + for (const auto& value : values) uuids.append(value.toString()); + auto* menu = new QMenu(this); + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->setToolTipsVisible(true); + addUpdateAction(menu, m_ctx, uuids, + [this, uuids] { updateMods(m_ctx, uuids, this); }); + menu->popup(QCursor::pos()); +} +void ModListPanel::coreStateChanged(const QStringList& uuids) { + const auto currentList = m_model->activeUuids(); + const QSet current(currentList.begin(), currentList.end()), + next(uuids.begin(), uuids.end()); + m_model->setCheckable(next - current, true); + m_model->setCheckable(current - next, false); + m_model->commitOrder(uuids); +} +void ModListPanel::commitOrder(const QStringList& uuids) { + m_model->commitOrder(uuids); + m_ctx.setActive(m_model->activeUuids()); + emit orderChanged(); +} +void ModListPanel::moveRow(int source, int target) { + m_proxy->moveRow(source, target, true); +} +void ModListPanel::dragEnded() { + m_ctx.setActive(m_model->activeUuids()); + emit orderChanged(); +} +void ModListPanel::selectionChanged(const QVariantList& values) { + QStringList uuids; + for (const auto& value : values) + if (!value.toString().isEmpty()) uuids.append(value.toString()); + emit selectionUpdated(uuids); +} +bool ModListPanel::eventFilter(QObject* object, QEvent* event) { + if (object == m_qml && + (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut)) + m_qml->rootContext()->setContextProperty(QStringLiteral("modListHasFocus"), + event->type() == QEvent::FocusIn); + return QWidget::eventFilter(object, event); +} +} // namespace px::modrim diff --git a/src/ui/panels/mod_list_panel.hpp b/src/ui/panels/mod_list_panel.hpp new file mode 100644 index 0000000..0c187fd --- /dev/null +++ b/src/ui/panels/mod_list_panel.hpp @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include +#include + +#include + +#include "ui/models/mod_list_proxy_model.hpp" +class QQmlEngine; +class QQuickWidget; +class QLineEdit; +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +class ModListPanel : public QWidget { + Q_OBJECT + Q_PROPERTY(bool compactMode READ compactMode WRITE setCompactMode NOTIFY + compactModeChanged) + Q_PROPERTY( + int highlightRevision READ highlightRevision NOTIFY highlightedChanged) + public: + ModListPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ~ModListPanel() override; + void loadMods(const QHash& mods, + const QStringList& active); + QStringList activeUuids() const; + ModListModel* model() const { return m_model; } + ModListProxyModel* proxyModel() const { return m_proxy; } + QQuickWidget* qmlWidget() const { return m_qml; } + void setSidebarFilter(std::optional> uuids); + void setSearchFilter(const QString& text); + bool selectUuid(const QString& uuid); + bool compactMode() const { return m_compactMode; } + int highlightRevision() const { return m_highlightRevision; } + void highlightRows(const QStringList& uuids); + QCoro::Task toggleRows(QList rows); + QCoro::Task refresh(); + QLineEdit* searchInput; + Q_INVOKABLE void setCompactMode(bool enabled); + Q_INVOKABLE bool isHighlighted(const QString& uuid) const; + Q_INVOKABLE void modSelected(const QString& uuid); + Q_INVOKABLE void clearSelection(); + Q_INVOKABLE QString uuidAt(int row) const; + Q_INVOKABLE int rowForUuid(const QString& uuid) const; + Q_INVOKABLE QCoro::QmlTask toggleCheck(int row) { return toggleRows({row}); } + Q_INVOKABLE QCoro::QmlTask toggleChecked(const QVariantList& rows); + Q_INVOKABLE void showContextMenu(const QVariantList& uuids); + Q_INVOKABLE bool isChecked(int row) const; + Q_INVOKABLE void commitOrder(const QStringList& uuids); + Q_INVOKABLE void moveRow(int source, int target); + Q_INVOKABLE void dragEnded(); + Q_INVOKABLE void selectionChanged(const QVariantList& uuids); + signals: + void modPicked(const QString& uuid); + void activeModsChanged(); + void orderChanged(); + void selectionUpdated(const QStringList& uuids); + void compactModeChanged(bool enabled); + void highlightedChanged(); + + protected: + bool eventFilter(QObject* object, QEvent* event) override; + + private: + int proxyToSourceRow(int row) const; + void coreStateChanged(const QStringList& uuids); + void setHighlighted(const QStringList& uuids); + CoreContext& m_ctx; + ModListModel* m_model; + ModListProxyModel* m_proxy; + QQuickWidget* m_qml; + bool m_compactMode; + QSet m_highlighted; + int m_highlightRevision = 0; + QTimer* m_highlightTimer; + std::uint64_t m_activeConnection, m_configConnection, m_modsConnection; +}; +} // namespace px::modrim diff --git a/src/ui/panels/restore_snapshot_dialog.cpp b/src/ui/panels/restore_snapshot_dialog.cpp new file mode 100644 index 0000000..216873f --- /dev/null +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -0,0 +1,220 @@ +#include "ui/panels/restore_snapshot_dialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/mods_config.hpp" + +namespace px::modrim { + +namespace { + +constexpr QStringView kSnapshotPrefix = u"ModsConfig_"; + +QString snapshotStem(const QString& path) { + const QString name = QFileInfo(path).fileName(); + const qsizetype dot = name.lastIndexOf(u'.'); + return dot > 0 ? name.left(dot) : name; +} + +bool allAsciiDigits(QStringView text) { + return std::all_of(text.begin(), text.end(), + [](QChar c) { return c >= u'0' && c <= u'9'; }); +} + +// The UTC time encoded in a snapshot name such as +// ModsConfig_20260922_120000[_suffix].xml. Non-digit or out-of-range fields are +// rejected like Python's strptime rejected them. +std::optional timestampFromStem(const QString& stem) { + if (!stem.startsWith(kSnapshotPrefix)) { + return std::nullopt; + } + const QStringList parts = stem.mid(kSnapshotPrefix.size()).split(u'_'); + if (parts.size() < 2 || parts[0].size() != 8 || parts[1].size() < 6) { + return std::nullopt; + } + const QString date = parts[0]; + const QString time = parts[1].left(6); + if (!allAsciiDigits(date) || !allAsciiDigits(time)) { + return std::nullopt; + } + const QDate day(date.left(4).toInt(), date.mid(4, 2).toInt(), + date.mid(6, 2).toInt()); + const QTime timeOfDay(time.left(2).toInt(), time.mid(2, 2).toInt(), + time.mid(4, 2).toInt()); + if (!day.isValid() || !timeOfDay.isValid()) { + return std::nullopt; + } + return QDateTime(day, timeOfDay, QTimeZone::utc()); +} + +QString formatUtc(const QDateTime& when) { + return when.toUTC().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")) + + QStringLiteral(" UTC"); +} + +// The name's timestamp when it has one, otherwise the file's modification time, +// otherwise the stem. +QString snapshotTimestamp(const QString& path) { + const QString stem = snapshotStem(path); + if (const auto named = timestampFromStem(stem)) { + return formatUtc(*named); + } + const QDateTime modified = QFileInfo(path).lastModified(); + return modified.isValid() ? formatUtc(modified) : stem; +} + +} // namespace + +RestoreSnapshotDialog::RestoreSnapshotDialog(const QStringList& snapshots, + QWidget* parent) + : QDialog(parent) { + setObjectName(QStringLiteral("restoreSnapshotDialog")); + setWindowTitle(QStringLiteral("Restore Mod List")); + resize(560, 420); + + m_snapshots.reserve(static_cast(snapshots.size())); + for (const QString& path : snapshots) { + m_snapshots.push_back(Snapshot{path, parseModsConfig(path)}); + } + + auto* layout = new QVBoxLayout(this); + + auto* instruction = + new QLabel(QStringLiteral("Choose a saved mod list. Your current list " + "will be backed up before it is replaced."), + this); + instruction->setWordWrap(true); + layout->addWidget(instruction); + + m_list = new QListWidget(this); + populateList(); + layout->addWidget(m_list); + + m_details = new QLabel(this); + m_details->setWordWrap(true); + layout->addWidget(m_details); + + auto* buttons = new QHBoxLayout(); + buttons->addStretch(); + + m_restore = new QPushButton(QStringLiteral("Restore"), this); + m_restore->setObjectName(QStringLiteral("primaryAction")); + m_restore->setEnabled(false); + connect(m_restore, &QPushButton::clicked, this, + &RestoreSnapshotDialog::onRestoreClicked); + + auto* cancel = new QPushButton(QStringLiteral("Cancel"), this); + cancel->setObjectName(QStringLiteral("appBtn")); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + + buttons->addWidget(cancel); + buttons->addWidget(m_restore); + layout->addLayout(buttons); + + connect(m_list, &QListWidget::currentItemChanged, this, + &RestoreSnapshotDialog::onCurrentItemChanged); + connect(m_list, &QListWidget::itemDoubleClicked, this, + &RestoreSnapshotDialog::onRestoreClicked); + + // Connected before selecting, so the initial selection updates the details + // and the button. + if (m_list->count() > 0) { + m_list->setCurrentRow(0); + } +} + +std::optional RestoreSnapshotDialog::selectedSnapshot() const { + return m_selected; +} + +void RestoreSnapshotDialog::populateList() { + for (std::size_t i = 0; i < m_snapshots.size(); ++i) { + const Snapshot& snapshot = m_snapshots[i]; + auto* item = new QListWidgetItem(m_list); + const QString timestamp = snapshotTimestamp(snapshot.path); + + if (snapshot.parsed) { + const qsizetype active = snapshot.parsed->activeMods.size(); + const QString noun = + active == 1 ? QStringLiteral("mod") : QStringLiteral("mods"); + item->setText(QStringLiteral(u"%1 \u2014 %2 active %3, RimWorld %4") + .arg(timestamp) + .arg(QString::number(active)) + .arg(noun) + .arg(snapshot.parsed->version)); + } else { + item->setText(QStringLiteral(u"%1 \u2014 Cannot be read").arg(timestamp)); + } + item->setData(Qt::UserRole, static_cast(i)); + item->setToolTip(QFileInfo(snapshot.path).fileName()); + } +} + +void RestoreSnapshotDialog::onCurrentItemChanged(QListWidgetItem* current) { + if (current == nullptr) { + m_selected.reset(); + m_restore->setEnabled(false); + m_details->clear(); + return; + } + + const Snapshot& snapshot = m_snapshots[static_cast( + current->data(Qt::UserRole).toInt())]; + const QString name = QFileInfo(snapshot.path).fileName(); + if (snapshot.parsed) { + m_selected = snapshot.path; + m_restore->setEnabled(true); + m_details->setText( + QStringLiteral("File: %1
    Active mods: %2 | Game " + "version: %3") + .arg(name) + .arg(QString::number(snapshot.parsed->activeMods.size())) + .arg(snapshot.parsed->version)); + } else { + m_selected.reset(); + m_restore->setEnabled(false); + m_details->setText(QStringLiteral("This saved mod list cannot be " + "read. %1 will not be restored.") + .arg(name)); + } +} + +void RestoreSnapshotDialog::onRestoreClicked() { + if (m_selected) { + accept(); + } +} + +ConfirmRestoreDialog::ConfirmRestoreDialog(const QString& snapshotName, + QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Question); + // The QMessageBox setter is a no-op on macOS; the QWidget setter applies + // there too. + QDialog::setWindowTitle(QStringLiteral("Restore Mod List")); + setText( + QStringLiteral("Restore the mod list saved in %1?").arg(snapshotName)); + setInformativeText( + QStringLiteral("This replaces your current active mod list. PxModRim " + "will save a backup of the current list first.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { + yes->setText(QStringLiteral("Restore")); + yes->setObjectName(QStringLiteral("primaryAction")); + } +} + +} // namespace px::modrim diff --git a/src/ui/panels/restore_snapshot_dialog.hpp b/src/ui/panels/restore_snapshot_dialog.hpp new file mode 100644 index 0000000..ca5713f --- /dev/null +++ b/src/ui/panels/restore_snapshot_dialog.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/models/metadata/structures.hpp" + +class QLabel; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +namespace px::modrim { + +// Lists saved mod lists (ModsConfig snapshots, in the order given, newest +// first) and lets the user pick one to restore. Snapshots that cannot be parsed +// are listed but cannot be selected. +class RestoreSnapshotDialog : public QDialog { + Q_OBJECT + + public: + explicit RestoreSnapshotDialog(const QStringList& snapshots, + QWidget* parent = nullptr); + + // The selected snapshot path, or nullopt while the selection is unreadable or + // absent. + std::optional selectedSnapshot() const; + + private slots: + void onCurrentItemChanged(QListWidgetItem* current); + void onRestoreClicked(); + + private: + struct Snapshot { + QString path; + std::optional parsed; + }; + + void populateList(); + + std::vector m_snapshots; + std::optional m_selected; + QListWidget* m_list = nullptr; + QLabel* m_details = nullptr; + QPushButton* m_restore = nullptr; +}; + +// Asks before the current mod list is replaced by a snapshot. +class ConfirmRestoreDialog : public QMessageBox { + public: + explicit ConfirmRestoreDialog(const QString& snapshotName, + QWidget* parent = nullptr); +}; + +} // namespace px::modrim diff --git a/src/ui/panels/settings_panel.cpp b/src/ui/panels/settings_panel.cpp new file mode 100644 index 0000000..b668b88 --- /dev/null +++ b/src/ui/panels/settings_panel.cpp @@ -0,0 +1,215 @@ +#include "ui/panels/settings_panel.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/mod_service.hpp" +#include "core/sort/community_service.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/context.hpp" +#include "ui/theme/palette.hpp" +namespace px::modrim { +namespace { +class FolderDialog : public QFileDialog { + public: + FolderDialog(QWidget* parent, const QString& title) + : QFileDialog(parent, title) { + setFileMode(QFileDialog::Directory); + setOption(QFileDialog::ShowDirsOnly); + } +}; +} // namespace +SettingsBackend::SettingsBackend(CoreContext& ctx, QDialog* dialog, + const std::vector& sections) + : QObject(dialog), m_ctx(ctx), m_dialog(dialog) { + for (auto* section : sections) + m_sections.append(QVariantMap{ + {"title", section->title()}, + {"source", section->source().toString()}, + {"section", QVariant::fromValue(static_cast(section))}}); + const auto& cfg = ctx.config(); + const auto text = [](const std::string& value) { + return QString::fromStdString(value); + }; + m_initial = {{"game", text(cfg.paths.game)}, + {"local", text(cfg.paths.local)}, + {"workshop", text(cfg.paths.workshop)}, + {"config", text(cfg.paths.config_folder)}, + {"compact", cfg.compact_mod_list}, + {"launchArgs", text(cfg.launch_args)}, + {"launchWrapper", text(cfg.launch_wrapper)}, + {"afterLaunch", static_cast(cfg.after_launch)}, + {"confirmErrors", cfg.launch_confirm_errors}, + {"confirmUnsaved", cfg.launch_confirm_unsaved}, + {"confirmRunning", cfg.launch_confirm_running}, + {"useAltIds", cfg.sort.use_alternative_package_ids}, + {"checkMissing", cfg.sort.check_missing_dependencies}, + {"useCommunity", cfg.sort.use_community_rules}}; + const auto path = communityRulesFile(); + m_communityStatus = QFileInfo::exists(path) + ? "Found: " + path + : QStringLiteral("Not downloaded"); + m_cacheAvailable = !cfg.paths.config_folder.empty(); + m_cacheStatus = m_cacheAvailable + ? QStringLiteral("Cache ready") + : QStringLiteral("No config folder — not available"); +} +QCoro::Task SettingsBackend::browse(QString key) { + static const QMap titles{ + {"game", "Select RimWorld Game Folder"}, + {"local", "Select Local Mods Folder"}, + {"workshop", "Select Workshop Mods Folder"}, + {"config", "Select RimWorld Config Folder"}}; + if (!titles.contains(key)) co_return; + QPointer guard(this); + const auto [result, picker] = + co_await awaitDialog(m_dialog, titles.value(key)); + const auto files = picker->selectedFiles(); + if (guard && result == QDialog::Accepted && !files.isEmpty()) + emit guard->pathPicked(key, files.front()); +} +QString SettingsBackend::localCandidate(const QString& gamePath) const { + const auto path = QDir(gamePath).filePath(QStringLiteral("Mods")); + return QFileInfo(path).isDir() ? path : QString{}; +} +void SettingsBackend::autoDetect() { + const auto paths = detectGamePaths(); + const std::pair values[]{ + {"game", &paths.game}, + {"local", &paths.local}, + {"workshop", &paths.workshop}, + {"config", &paths.config_folder}}; + for (const auto& [key, value] : values) + if (!value->empty()) + emit pathPicked(QString::fromLatin1(key), QString::fromStdString(*value)); +} +QCoro::Task SettingsBackend::downloadCommunityRules() { + m_communityStatus = QStringLiteral("Downloading…"); + m_communityBusy = true; + emit communityChanged(); + if (!m_ctx.hasConfigService()) { + m_communityStatus = QStringLiteral("Download failed"); + m_communityBusy = false; + emit communityChanged(); + co_return; + } + LoadingState loading; + auto* progress = + new QProgressDialog(QStringLiteral("Downloading community rules…"), + QString{}, 0, 100, m_dialog); + progress->setWindowModality(Qt::WindowModal); + progress->setCancelButton(nullptr); + progress->setMinimumDuration(0); + connect(&loading, &LoadingState::changed, progress, + [progress](const QString& status, int percent, int, int) { + progress->setLabelText(status); + progress->setValue(percent); + }); + progress->show(); + sort::CommunityRulesService service(m_ctx.configService(), m_ctx.network()); + QPointer guard(this); + QPointer progressGuard(progress); + const auto path = co_await service.ensureRules(loading, true); + if (progressGuard) { + progressGuard->close(); + progressGuard->deleteLater(); + } + if (!guard) co_return; + guard->m_communityStatus = + path ? "Downloaded: " + *path : QStringLiteral("Download failed"); + guard->m_communityBusy = false; + emit guard->communityChanged(); +} +QCoro::Task SettingsBackend::clearCache() { + m_cacheStatus = QStringLiteral("Clearing…"); + m_cacheBusy = true; + emit cacheChanged(); + QPointer guard(this); + const auto result = co_await m_ctx.modService().startupImpact().clear(); + if (!guard) co_return; + guard->m_cacheStatus = result ? QStringLiteral("Cache cleared") + : "Error: " + result.error().message; + guard->m_cacheBusy = false; + emit guard->cacheChanged(); +} +void SettingsBackend::reportSaveError(const QString& message) { + auto* box = new QMessageBox(QMessageBox::Warning, QStringLiteral("Settings"), + message, QMessageBox::Ok, m_dialog); + box->setAttribute(Qt::WA_DeleteOnClose); + box->open(); +} +SettingsPanel::SettingsPanel( + CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + const std::vector& factories) + : QDialog(parent), + m_config(ctx.config()), + m_backend(nullptr), + m_qml(new QQuickWidget(engine, this)) { + setObjectName(QStringLiteral("settingsPanel")); + setWindowTitle(QStringLiteral("Settings")); + setModal(true); + resize(700, 560); + for (const auto& factory : factories) m_sections.push_back(factory(this)); + m_backend = new SettingsBackend(ctx, this, m_sections); + connect(m_backend, &SettingsBackend::saveRequested, this, + &SettingsPanel::save); + connect(m_backend, &SettingsBackend::cancelRequested, this, &QDialog::reject); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_2")); + m_qml->rootContext()->setContextProperty(QStringLiteral("settings"), + m_backend); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Settings.qml"))); + layout->addWidget(m_qml); + // A QML click handler must finish before its root object is destroyed. + connect( + this, &QDialog::finished, this, [this] { m_qml->setSource(QUrl{}); }, + Qt::QueuedConnection); +} +SettingsPanel::SettingsPanel(AppContext& app, QQmlEngine* engine, + QWidget* parent) + : SettingsPanel(app.coreContext(), engine, parent, app.settingsSections()) { +} +SettingsPanel::~SettingsPanel() { m_qml->setSource(QUrl{}); } +void SettingsPanel::save(const QVariantMap& values) { + auto updated = m_config; + const auto text = [&](const char* key) { + return values.value(QLatin1String(key)).toString().trimmed().toStdString(); + }; + updated.paths.game = text("game"); + updated.paths.local = text("local"); + updated.paths.workshop = text("workshop"); + updated.paths.config_folder = text("config"); + updated.sort.use_alternative_package_ids = values.value("useAltIds").toBool(); + updated.sort.check_missing_dependencies = + values.value("checkMissing").toBool(); + updated.sort.use_community_rules = values.value("useCommunity").toBool(); + updated.compact_mod_list = values.value("compact").toBool(); + updated.launch_args = text("launchArgs"); + updated.launch_wrapper = text("launchWrapper"); + updated.after_launch = + static_cast(values.value("afterLaunch").toInt()); + updated.launch_confirm_errors = values.value("confirmErrors").toBool(); + updated.launch_confirm_unsaved = values.value("confirmUnsaved").toBool(); + updated.launch_confirm_running = values.value("confirmRunning").toBool(); + for (auto* section : m_sections) { + const auto result = section->apply(); + if (!result) { + m_backend->reportSaveError(result.error().message); + return; + } + } + m_config = std::move(updated); + accept(); +} +} // namespace px::modrim diff --git a/src/ui/panels/settings_panel.hpp b/src/ui/panels/settings_panel.hpp new file mode 100644 index 0000000..0373025 --- /dev/null +++ b/src/ui/panels/settings_panel.hpp @@ -0,0 +1,75 @@ +#pragma once +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "ui/settings_section.hpp" +class QQmlEngine; +class QQuickWidget; +namespace px::modrim { +class AppContext; +class SettingsBackend : public QObject { + Q_OBJECT + Q_PROPERTY(QVariantMap initial READ initial CONSTANT) + Q_PROPERTY(QVariantList sections READ sections CONSTANT) + Q_PROPERTY( + QString communityStatus READ communityStatus NOTIFY communityChanged) + Q_PROPERTY(bool communityBusy READ communityBusy NOTIFY communityChanged) + Q_PROPERTY(QString cacheStatus READ cacheStatus NOTIFY cacheChanged) + Q_PROPERTY(bool cacheAvailable READ cacheAvailable CONSTANT) + Q_PROPERTY(bool cacheBusy READ cacheBusy NOTIFY cacheChanged) + public: + SettingsBackend(CoreContext& ctx, QDialog* dialog, + const std::vector& sections); + QVariantMap initial() const { return m_initial; } + QVariantList sections() const { return m_sections; } + QString communityStatus() const { return m_communityStatus; } + bool communityBusy() const { return m_communityBusy; } + QString cacheStatus() const { return m_cacheStatus; } + bool cacheAvailable() const { return m_cacheAvailable; } + bool cacheBusy() const { return m_cacheBusy; } + Q_INVOKABLE QCoro::Task browse(QString key); + Q_INVOKABLE QString localCandidate(const QString& gamePath) const; + Q_INVOKABLE void autoDetect(); + Q_INVOKABLE QCoro::Task downloadCommunityRules(); + Q_INVOKABLE QCoro::Task clearCache(); + Q_INVOKABLE void save(const QVariantMap& values) { + emit saveRequested(values); + } + Q_INVOKABLE void cancel() { emit cancelRequested(); } + void reportSaveError(const QString& message); + signals: + void pathPicked(const QString& key, const QString& path); + void saveRequested(const QVariantMap& values); + void cancelRequested(); + void communityChanged(); + void cacheChanged(); + + private: + CoreContext& m_ctx; + QDialog* m_dialog; + QVariantMap m_initial; + QVariantList m_sections; + QString m_communityStatus, m_cacheStatus; + bool m_communityBusy = false, m_cacheBusy = false, m_cacheAvailable = false; +}; +class SettingsPanel : public QDialog { + Q_OBJECT + public: + SettingsPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + const std::vector& sections = {}); + SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); + ~SettingsPanel() override; + const AppConfig& getConfig() const { return m_config; } + + private: + void save(const QVariantMap& values); + AppConfig m_config; + std::vector m_sections; + SettingsBackend* m_backend; + QQuickWidget* m_qml; +}; +} // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.cpp b/src/ui/panels/sidebar_panel.cpp new file mode 100644 index 0000000..90d4edb --- /dev/null +++ b/src/ui/panels/sidebar_panel.cpp @@ -0,0 +1,51 @@ +#include "ui/panels/sidebar_panel.hpp" + +#include + +#include "core/context.hpp" +#include "core/services/diagnostics_service.hpp" +namespace px::modrim { +SidebarPanel::SidebarPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent) + : FilterSidebar(engine, nullptr, parent), + m_ctx(ctx), + m_model(new SidebarModel(this)) { + if (rootObject()) + rootObject()->setProperty("model", QVariant::fromValue(m_model)); + connect(this, &FilterSidebar::activated, this, &SidebarPanel::onActivated); + m_sidebarConnection = + m_ctx.diagnosticsService().sidebarEntriesChanged.connect( + [this](const auto& entries) { setEntries(entries); }); +} +SidebarPanel::~SidebarPanel() { + m_ctx.diagnosticsService().sidebarEntriesChanged.disconnect( + m_sidebarConnection); +} +void SidebarPanel::setEntries(const QList& entries) { + const bool reset = entries.size() != m_entries.size(); + m_entries = entries; + m_model->updateEntries(entries); + if (reset) + if (const auto current = currentEntry()) emit entrySelected(*current); +} +std::optional SidebarPanel::currentEntry() const { + const int selected = currentIndex(); + if (selected >= 0 && selected < m_entries.size()) return m_entries[selected]; + return std::nullopt; +} +void SidebarPanel::selectAllEntry() { + auto* root = rootObject(); + auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (!view) return; + for (int row = 0; row < m_entries.size(); ++row) + if (m_entries[row].kind == view::SidebarEntry::Kind::kAll) { + view->setProperty("currentIndex", row); + onActivated(row); + return; + } +} +void SidebarPanel::onActivated(int row) { + if (row >= 0 && row < m_entries.size()) emit entrySelected(m_entries[row]); +} +} // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.hpp b/src/ui/panels/sidebar_panel.hpp new file mode 100644 index 0000000..de603fe --- /dev/null +++ b/src/ui/panels/sidebar_panel.hpp @@ -0,0 +1,29 @@ +#pragma once +#include + +#include "ui/components/filter_sidebar.hpp" +#include "ui/models/sidebar_model.hpp" +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +class SidebarPanel : public FilterSidebar { + Q_OBJECT + public: + SidebarPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent = nullptr); + ~SidebarPanel() override; + void setEntries(const QList& entries); + std::optional currentEntry() const; + void selectAllEntry(); + SidebarModel* model() const { return m_model; } + signals: + void entrySelected(const px::modrim::view::SidebarEntry& entry); + + private: + void onActivated(int row); + CoreContext& m_ctx; + SidebarModel* m_model; + QList m_entries; + std::uint64_t m_sidebarConnection; +}; +} // namespace px::modrim diff --git a/src/ui/panels/startup_impact_data.cpp b/src/ui/panels/startup_impact_data.cpp new file mode 100644 index 0000000..6499be9 --- /dev/null +++ b/src/ui/panels/startup_impact_data.cpp @@ -0,0 +1,219 @@ +#include "ui/panels/startup_impact_data.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "core/services/startup_impact_service/labels.hpp" +#include "core/services/startup_impact_service/phases.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +using namespace startup_impact; +namespace { +constexpr double kMinSeconds = 0.001; +QString phaseColor(std::size_t i) { + static constexpr std::array kColors{ + "#b57bee", "#f06ba8", "#43b581", "#f0883e", "#4fb3e8", "#e8c547"}; + return i < kColors.size() ? QString::fromLatin1(kColors[i]) + : paletteValue(u"TEXT_DIM"); +} +QString tooltip(const QMap& metrics) { + QList> sorted; + for (auto it = metrics.cbegin(); it != metrics.cend(); ++it) + sorted.append({it.key(), it.value()}); + std::stable_sort( + sorted.begin(), sorted.end(), + [](const auto& a, const auto& b) { return a.second > b.second; }); + QStringList lines; + for (qsizetype i = 0; i < std::min(8, sorted.size()); ++i) + if (sorted[i].second >= kMinSeconds) + lines.append(sorted[i].first + ": " + formatDuration(sorted[i].second)); + if (sorted.size() > 8) + lines.append(QStringLiteral("+ %1 more").arg(sorted.size() - 8)); + return lines.join('\n'); +} +using Entry = std::tuple*>; +QVariantList phaseRows(const QList& entries, + const std::optional& selected) { + using Contributor = std::tuple; + std::array, kPhaseCount> contributors; + std::array, kPhaseCount> metrics; + PhaseTotals sums{}; + for (const auto& [name, key, base, values] : entries) { + const auto totals = phaseTotals(*values); + for (std::size_t p = 0; p < kPhaseCount; ++p) { + if (totals[p] < kMinSeconds) continue; + contributors[p].append({totals[p], name, key, base}); + sums[p] += totals[p]; + } + for (auto it = values->cbegin(); it != values->cend(); ++it) + metrics[phaseIndex(metricPhase(it.key()))] + [metricLabel(it.key().section('|', 0, 0))] += it.value(); + } + double grand = 0; + for (double value : sums) grand += value; + const double maximum = *std::max_element(sums.begin(), sums.end()); + std::array order{0, 1, 2, 3, 4, 5, 6}; + std::stable_sort(order.begin(), order.end(), + [&](auto a, auto b) { return sums[a] > sums[b]; }); + QVariantList rows; + for (auto p : order) { + if (contributors[p].isEmpty()) continue; + auto& items = contributors[p]; + std::sort(items.begin(), items.end(), std::greater{}); + QVariantList mods; + const double top = std::get<0>(items.front()); + for (const auto& [seconds, name, key, base] : items) + mods.append(QVariantMap{{"name", name}, + {"key", key}, + {"value", formatDuration(seconds)}, + {"fraction", seconds / top}, + {"base", base}, + {"selected", selected && key == *selected}}); + rows.append(QVariantMap{ + {"label", phaseLabel(static_cast(p))}, + {"color", phaseColor(p)}, + {"seconds", sums[p]}, + {"value", formatDuration(sums[p])}, + {"share", grand > 0 ? QString::number(static_cast( + std::nearbyint(100 * sums[p] / grand))) + + '%' + : QString{}}, + {"fraction", sums[p] / maximum}, + {"tooltip", tooltip(metrics[p])}, + {"mods", mods}}); + } + return rows; +} +} // namespace +QVariantMap buildStartupImpact(const StartupImpactReport& report, + const QStringList& activePids, + const std::optional& selectedPid, + double estimatedS) { + QSet active; + for (const auto& pid : activePids) active.insert(normalizePackageId(pid)); + const auto selected = + selectedPid && !selectedPid->isEmpty() + ? std::optional{normalizePackageId(*selectedPid)} + : std::nullopt; + const StartupImpactMod* base = nullptr; + QList mods; + double modsSeconds = 0, offSeconds = 0; + for (const auto& mod : report.mods) { + offSeconds += mod.offThreadTotalImpactS; + if (mod.packageId && *mod.packageId == kBaseGamePid) { + if (!base) base = &mod; + } else { + mods.append(&mod); + modsSeconds += mod.totalImpactS; + } + } + std::stable_sort(mods.begin(), mods.end(), [](auto a, auto b) { + return a->totalImpactS > b->totalImpactS; + }); + const double baseSeconds = base ? base->totalImpactS : 0; + const double barTotal = baseSeconds + modsSeconds + offSeconds; + const double maxMod = mods.isEmpty() ? 0 : mods.front()->totalImpactS; + QVariantList rows, legend, summary; + QList entries; + int activeCount = 0; + QString selectedKey; + for (qsizetype index = 0; index < mods.size(); ++index) { + const auto& mod = *mods[index]; + const QString key = mod.packageId && !mod.packageId->isEmpty() + ? *mod.packageId + : mod.modName; + const bool isActive = mod.packageId && active.contains(*mod.packageId); + const bool isSelected = + mod.packageId && selected && *mod.packageId == *selected; + if (isActive) ++activeCount; + if (isSelected && selectedKey.isEmpty()) selectedKey = key; + const auto totals = phaseTotals(mod.metrics); + std::array, kPhaseCount> grouped; + for (auto it = mod.metrics.cbegin(); it != mod.metrics.cend(); ++it) + grouped[phaseIndex(metricPhase(it.key()))][metricLabel(it.key())] += + it.value(); + QList order; + QVariantList segments, phases; + for (std::size_t p = 0; p < kPhaseCount; ++p) { + if (totals[p] < kMinSeconds) continue; + order.append(p); + segments.append( + QVariantMap{{"color", phaseColor(p)}, + {"fraction", maxMod > 0 ? totals[p] / maxMod : 0}}); + } + std::stable_sort(order.begin(), order.end(), + [&](auto a, auto b) { return totals[a] > totals[b]; }); + for (auto p : order) + phases.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, + {"color", phaseColor(p)}, + {"value", formatDuration(totals[p])}, + {"fraction", totals[p] / totals[order.front()]}, + {"tooltip", tooltip(grouped[p])}}); + rows.append(QVariantMap{ + {"key", key}, + {"name", mod.modName}, + {"rank", index + 1}, + {"seconds", mod.totalImpactS}, + {"time", formatDuration(mod.totalImpactS)}, + {"color", impactColor(mod.totalImpactS)}, + {"active", isActive}, + {"selected", isSelected}, + {"off_thread", mod.offThreadTotalImpactS >= kMinSeconds + ? formatDuration(mod.offThreadTotalImpactS) + : QString{}}, + {"dominant", order.isEmpty() + ? QString{} + : phaseLabel(static_cast(order.front()))}, + {"segments", segments}, + {"phases", phases}}); + entries.append({mod.modName, key, false, &mod.metrics}); + } + for (std::size_t p = 0; p < kPhaseCount; ++p) + legend.append(QVariantMap{{"label", phaseLabel(static_cast(p))}, + {"color", phaseColor(p)}}); + const std::array labels{QStringLiteral("Base game"), + QStringLiteral("Mods, main thread"), + QStringLiteral("Off-thread (parallel)")}; + const std::array values{baseSeconds, modsSeconds, offSeconds}; + for (std::size_t i = 0; i < values.size(); ++i) + if (values[i] >= kMinSeconds) + summary.append(QVariantMap{ + {"label", labels[i]}, + {"value", formatDuration(values[i])}, + {"fraction", barTotal > 0 ? values[i] / barTotal : 0}, + {"color", paletteValue(i == 0 ? u"TEXT_DIM" : u"PRIMARY")}, + {"parallel", i == 2}}); + const auto modsPhases = phaseRows(entries, selected); + const QMap empty; + entries.prepend({QStringLiteral("Base game"), QString(kBaseGamePid), true, + base ? &base->metrics : &empty}); + auto timestamp = QDateTime::fromString(report.timestamp, Qt::ISODateWithMs); + if (!timestamp.isValid()) + timestamp = QDateTime::fromString(report.timestamp, Qt::ISODate); + return { + {"timestamp", timestamp.isValid() + ? timestamp.toLocalTime().toString("yyyy-MM-dd HH:mm") + : report.timestamp}, + {"last_launch", + formatDuration(report.loadingTimeS != 0 ? report.loadingTimeS + : baseSeconds + modsSeconds)}, + {"estimated", + activePids.isEmpty() ? QString{} : formatDuration(estimatedS)}, + {"summary", summary}, + {"base_value", formatDuration(baseSeconds)}, + {"mods_value", formatDuration(modsSeconds)}, + {"off_value", formatDuration(offSeconds)}, + {"legend", legend}, + {"mods", rows}, + {"active_count", activeCount}, + {"selected_key", selectedKey}, + {"phases_with_base", phaseRows(entries, selected)}, + {"phases_mods_only", modsPhases}}; +} +} // namespace px::modrim diff --git a/src/ui/panels/startup_impact_data.hpp b/src/ui/panels/startup_impact_data.hpp new file mode 100644 index 0000000..2457546 --- /dev/null +++ b/src/ui/panels/startup_impact_data.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +#include "core/services/startup_impact_service/models.hpp" +#include "ui/models/impact.hpp" + +namespace px::modrim { +QVariantMap buildStartupImpact( + const startup_impact::StartupImpactReport& report, + const QStringList& activePids, const std::optional& selectedPid, + double estimatedS); +} // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.cpp b/src/ui/panels/time_analytics_panel.cpp new file mode 100644 index 0000000..2e29ffd --- /dev/null +++ b/src/ui/panels/time_analytics_panel.cpp @@ -0,0 +1,68 @@ +#include "ui/panels/time_analytics_panel.hpp" + +#include +#include +#include +#include +#include + +#include "ui/components/dialog_chrome.hpp" +#include "ui/panels/startup_impact_data.hpp" +#include "ui/theme/palette.hpp" +namespace px::modrim { +TimeAnalyticsPanel::TimeAnalyticsPanel( + startup_impact::StartupImpactService* service, QQmlEngine* engine, + QWidget* parent) + : QWidget(parent), + m_service(service), + m_qml(new QQuickWidget(engine, this)) { + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setAttribute(Qt::WA_AlwaysStackOnTop, false); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/TimeAnalytics.qml"))); + layout->addWidget(m_qml); +} +QCoro::Task TimeAnalyticsPanel::setData(std::optional pid, + QStringList activePids) { + const auto token = ++m_requestToken; + QPointer root = m_qml->rootObject(); + if (!root) co_return; + if (!m_service) { + root->setProperty("sourceData", QVariant{}); + co_return; + } + QPointer guard(this); + auto result = co_await m_service->snapshot(activePids); + if (!guard || !root || token != guard->m_requestToken) co_return; + if (!result || !result->report) { + root->setProperty("sourceData", QVariant{}); + co_return; + } + double estimated = result->baseGameAvg; + for (const auto& total : result->totals) + estimated += total.onThread + total.offThread; + root->setProperty( + "sourceData", + buildStartupImpact(*result->report, activePids, pid, estimated)); +} +void TimeAnalyticsPanel::clear() { + ++m_requestToken; + if (auto* root = m_qml->rootObject()) + root->setProperty("sourceData", QVariant{}); +} +StartupImpactDialog::StartupImpactDialog( + startup_impact::StartupImpactService* service, QQmlEngine* engine, + QWidget* parent) + : QDialog(parent), panel(new TimeAnalyticsPanel(service, engine, this)) { + setWindowTitle(QStringLiteral("Startup impact")); + setAttribute(Qt::WA_DeleteOnClose); + resize(900, 720); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(panel); + installDialogChrome(this); +} +} // namespace px::modrim diff --git a/src/ui/panels/time_analytics_panel.hpp b/src/ui/panels/time_analytics_panel.hpp new file mode 100644 index 0000000..3d10dec --- /dev/null +++ b/src/ui/panels/time_analytics_panel.hpp @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include +#include + +#include + +#include "core/services/startup_impact_service/service.hpp" +class QQmlEngine; +class QQuickWidget; +namespace px::modrim { +class TimeAnalyticsPanel : public QWidget { + Q_OBJECT + public: + TimeAnalyticsPanel(startup_impact::StartupImpactService* service, + QQmlEngine* engine, QWidget* parent); + QCoro::Task setData(std::optional pid, QStringList activePids); + void clear(); + + private: + startup_impact::StartupImpactService* m_service; + QQuickWidget* m_qml; + quint64 m_requestToken = 0; +}; +class StartupImpactDialog : public QDialog { + Q_OBJECT + public: + StartupImpactDialog(startup_impact::StartupImpactService* service, + QQmlEngine* engine, QWidget* parent); + TimeAnalyticsPanel* panel; +}; +} // namespace px::modrim diff --git a/src/ui/panels/update_dialog.cpp b/src/ui/panels/update_dialog.cpp new file mode 100644 index 0000000..aa7078c --- /dev/null +++ b/src/ui/panels/update_dialog.cpp @@ -0,0 +1,59 @@ +#include "ui/panels/update_dialog.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace px::modrim { +UpdateDialog::UpdateDialog(const services::ReleaseInfo& release, + const QString& currentVersion, QWidget* parent) + : QDialog(parent) { + setWindowTitle(tr("Update Available")); + resize(560, 460); + auto* layout = new QVBoxLayout(this); + auto* title = + new QLabel(tr("PxModRim %1 is available").arg(release.tag), this); + title->setStyleSheet(QStringLiteral("font-size: 16px; font-weight: 600;")); + layout->addWidget(title); + layout->addWidget( + new QLabel(tr("You are running %1.").arg(currentVersion), this)); + auto* notes = new QTextBrowser(this); + notes->setOpenLinks(false); + notes->setOpenExternalLinks(false); + connect(notes, &QTextBrowser::anchorClicked, this, [](const QUrl& url) { + const QString scheme = url.scheme().toLower(); + if (scheme == QStringLiteral("http") || scheme == QStringLiteral("https")) + QDesktopServices::openUrl(url); + }); + notes->setMarkdown(release.notes.isEmpty() ? tr("No release notes.") + : release.notes); + layout->addWidget(notes, 1); + auto* note = + new QLabel(tr("Automatic updating may be added later. For now, " + "downloading the release " + "yourself is the safest way to avoid update bugs."), + this); + note->setWordWrap(true); + layout->addWidget(note); + auto* buttons = new QHBoxLayout; + auto* skip = new QPushButton(tr("Skip This Version"), this); + skip->setObjectName(QStringLiteral("appBtn")); + connect(skip, &QPushButton::clicked, this, [this] { done(kSkipResult); }); + auto* later = new QPushButton(tr("Later"), this); + later->setObjectName(QStringLiteral("appBtn")); + connect(later, &QPushButton::clicked, this, &QDialog::reject); + auto* download = new QPushButton(tr("Download"), this); + download->setObjectName(QStringLiteral("primaryAction")); + download->setDefault(true); + connect(download, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(skip); + buttons->addStretch(); + buttons->addWidget(later); + buttons->addWidget(download); + layout->addLayout(buttons); +} +} // namespace px::modrim diff --git a/src/ui/panels/update_dialog.hpp b/src/ui/panels/update_dialog.hpp new file mode 100644 index 0000000..794f46a --- /dev/null +++ b/src/ui/panels/update_dialog.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include "core/services/update_service.hpp" + +namespace px::modrim { +inline constexpr int kSkipResult = 2; + +class UpdateDialog : public QDialog { + public: + UpdateDialog(const services::ReleaseInfo& release, + const QString& currentVersion, QWidget* parent = nullptr); +}; +} // namespace px::modrim diff --git a/src/ui/panels/upload_report_dialog.cpp b/src/ui/panels/upload_report_dialog.cpp new file mode 100644 index 0000000..0abb83f --- /dev/null +++ b/src/ui/panels/upload_report_dialog.cpp @@ -0,0 +1,168 @@ +#include "ui/panels/upload_report_dialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/providers/base.hpp" +#include "core/support/report.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/context.hpp" + +namespace px::modrim { +UploadConfirmDialog::UploadConfirmDialog(QWidget* parent) : QDialog(parent) { + setObjectName(QStringLiteral("uploadConfirmDialog")); + setWindowTitle(tr("Upload Log & System Info")); + setModal(true); + resize(520, 320); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + layout->setSpacing(16); + auto* description = new QLabel( + tr("This action generates and uploads an anonymized diagnostic report to " + "help diagnose issues.\n\n" + "The report includes:\n" + " • Recent application log contents (last ~2 MB)\n" + " • System details (OS, architecture, Qt)\n" + " • RimWorld version and configured directory paths\n" + " • Mod collection count and active mod list in load order\n\n" + "Privacy & security:\n" + " • Your home directory path is replaced with '~'\n" + " • Your username in other user-directory paths is replaced with " + "''\n\n" + "Notice: The uploaded report will be publicly viewable by anyone with " + "the link."), + this); + description->setWordWrap(true); + layout->addWidget(description); + layout->addStretch(); + auto* buttons = new QHBoxLayout; + buttons->setSpacing(10); + auto* save = new QPushButton(tr("Save to file instead…"), this); + save->setObjectName(QStringLiteral("appBtn")); + connect(save, &QPushButton::clicked, this, + [this] { done(static_cast(UploadChoice::kSaveToFile)); }); + buttons->addWidget(save); + buttons->addStretch(); + auto* cancel = new QPushButton(tr("Cancel"), this); + cancel->setObjectName(QStringLiteral("appBtn")); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(cancel); + auto* upload = new QPushButton(tr("Upload"), this); + upload->setObjectName(QStringLiteral("primaryAction")); + connect(upload, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(upload); + layout->addLayout(buttons); +} + +UploadFailedDialog::UploadFailedDialog(const QString& errorMessage, + QWidget* parent) + : QDialog(parent) { + setObjectName(QStringLiteral("uploadFailedDialog")); + setWindowTitle(tr("Upload Failed")); + setModal(true); + resize(460, 220); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(20, 20, 20, 20); + layout->setSpacing(16); + auto* title = new QLabel(tr("Couldn't upload the report"), this); + title->setObjectName(QStringLiteral("dialogTitle")); + layout->addWidget(title); + auto* message = + new QLabel(tr("An error occurred while uploading:\n\n%1\n\n" + "Would you like to save the report to a file instead?") + .arg(errorMessage), + this); + message->setTextFormat(Qt::PlainText); + message->setWordWrap(true); + layout->addWidget(message); + layout->addStretch(); + auto* buttons = new QHBoxLayout; + buttons->addStretch(); + auto* close = new QPushButton(tr("Close"), this); + close->setObjectName(QStringLiteral("appBtn")); + connect(close, &QPushButton::clicked, this, &QDialog::reject); + buttons->addWidget(close); + auto* save = new QPushButton(tr("Save to file…"), this); + save->setObjectName(QStringLiteral("primaryAction")); + connect(save, &QPushButton::clicked, this, &QDialog::accept); + buttons->addWidget(save); + layout->addLayout(buttons); +} + +namespace { +class SaveReportFileDialog : public QFileDialog { + public: + explicit SaveReportFileDialog(QWidget* parent) + : QFileDialog(parent, tr("Save Log & System Info"), + QStringLiteral("pxmodrim-report.txt"), + tr("Text Files (*.txt);;All Files (*)")) { + setAcceptMode(QFileDialog::AcceptSave); + } +}; + +QCoro::Task saveReport(QWidget* parent, QString report, Notifier& toast) { + auto [result, dialog] = co_await awaitDialog(parent); + const QStringList files = dialog->selectedFiles(); + if (result != QDialog::Accepted || files.isEmpty()) co_return; + const QString path = files.first(); + const QString error = co_await providers::runOnPool( + nullptr, [path, report = std::move(report)] { + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly)) return file.errorString(); + const QByteArray bytes = report.toUtf8(); + if (file.write(bytes) != bytes.size() || !file.commit()) + return file.errorString(); + return QString{}; + }); + if (!error.isEmpty()) + toast.error(QObject::tr("Failed to save file: %1").arg(error), 5000); + else + toast.success( + QObject::tr("Report saved to %1").arg(QFileInfo(path).fileName()), + 3000); +} +} // namespace + +QCoro::Task> handleUploadReport(QWidget* parent, + CoreContext& ctx, + Notifier& toast) { + QPointer owner(parent); + auto [choice, dialog] = co_await awaitDialog(parent); + Q_UNUSED(dialog); + if (choice == static_cast(UploadChoice::kCancel)) co_return std::nullopt; + support::ReportSource source{ctx.gameVersion(), ctx.config().paths, + ctx.allMods(), ctx.activeUuids()}; + const QString report = co_await providers::runOnPool( + nullptr, + [source = std::move(source)] { return support::buildReport(source); }); + if (parent && !owner) co_return std::nullopt; + if (choice == static_cast(UploadChoice::kSaveToFile)) { + co_await saveReport(owner, report, toast); + co_return std::nullopt; + } + toast.info(QObject::tr("Uploading log & system info…"), 2000); + QString endpoint = QString::fromStdString(ctx.config().log_upload_endpoint); + if (endpoint.isEmpty()) endpoint = QString(support::kDefaultUploadEndpoint); + support::PasteUploader uploader(ctx.network(), std::move(endpoint)); + auto uploaded = co_await uploader.upload(report); + if (parent && !owner) co_return std::nullopt; + if (!uploaded) { + auto [result, failed] = co_await awaitDialog( + uploaded.error().message, owner.data()); + Q_UNUSED(failed); + if (result == QDialog::Accepted) co_await saveReport(owner, report, toast); + co_return std::nullopt; + } + QGuiApplication::clipboard()->setText(*uploaded); + toast.success(QObject::tr("Link copied: %1").arg(*uploaded), 5000); + co_return *uploaded; +} +} // namespace px::modrim diff --git a/src/ui/panels/upload_report_dialog.hpp b/src/ui/panels/upload_report_dialog.hpp new file mode 100644 index 0000000..bc37054 --- /dev/null +++ b/src/ui/panels/upload_report_dialog.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include + +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +class Notifier; +enum class UploadChoice { + kCancel = QDialog::Rejected, + kUpload = QDialog::Accepted, + kSaveToFile = 2 +}; +class UploadConfirmDialog : public QDialog { + public: + explicit UploadConfirmDialog(QWidget* parent = nullptr); +}; +class UploadFailedDialog : public QDialog { + public: + explicit UploadFailedDialog(const QString& errorMessage, + QWidget* parent = nullptr); +}; +QCoro::Task> handleUploadReport(QWidget* parent, + CoreContext& ctx, + Notifier& toast); +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.cpp b/src/ui/plugins/downloads/model.cpp new file mode 100644 index 0000000..7728b3e --- /dev/null +++ b/src/ui/plugins/downloads/model.cpp @@ -0,0 +1,221 @@ +#include "ui/plugins/downloads/model.hpp" + +namespace px::modrim { +namespace { +bool isActive(const QString& state) { + return state == QLatin1String("queued") || + state == QLatin1String("checking") || + state == QLatin1String("downloading"); +} +QString finalState(qint64 total) { + return total > 0 ? QStringLiteral("updated") : QStringLiteral("unchanged"); +} +} // namespace + +DownloadsModel::DownloadsModel(QObject* parent) + : QAbstractListModel(parent), m_timer(this), m_speedTimer(this) { + m_timer.setSingleShot(true); + m_timer.setInterval(150); + connect(&m_timer, &QTimer::timeout, this, &DownloadsModel::flush); + m_speedTimer.setInterval(1000); + connect(&m_speedTimer, &QTimer::timeout, this, &DownloadsModel::sampleSpeed); +} + +void DownloadsModel::begin(const QStringList& ids, + const QHash& titles) { + m_timer.stop(); + m_dirty.clear(); + m_bytes = m_sampledBytes = 0; + m_speed = 0; + m_history.clear(); + emit speedChanged(); + setPhase({}); + beginResetModel(); + m_rows.clear(); + m_byId.clear(); + m_rows.reserve(ids.size()); + for (const auto& id : ids) { + m_byId.insert(id, m_rows.size()); + m_rows.append(Row{id, titles.value(id, id)}); + } + recount(); + select(); + endResetModel(); + emit summaryChanged(); +} + +void DownloadsModel::apply(const DownloadItemStatus& status) { + const auto found = m_byId.constFind(status.modId); + if (found == m_byId.cend()) return; + auto& row = m_rows[*found]; + const auto done = status.status == QLatin1String("error") ? row.bytesDone + : status.bytesDone; + m_bytes += done - row.bytesDone; + row.bytesDone = done; + if (status.status == QLatin1String("error")) { + row.state = QStringLiteral("failed"); + row.error = status.error; + row.progress = 0; + } else if (status.status != QLatin1String("success") && + status.bytesTotal > 0 && status.bytesDone < status.bytesTotal) { + row.state = QStringLiteral("downloading"); + row.progress = static_cast(status.bytesDone) / status.bytesTotal; + } else { + row.state = finalState(status.bytesTotal); + row.progress = 1; + } + mark(row); +} + +void DownloadsModel::setTitle(const DownloadItemTitle& item) { + const auto found = m_byId.constFind(item.modId); + if (found == m_byId.cend()) return; + auto& row = m_rows[*found]; + if (row.title == row.id) row.title = item.title; + if (row.state == QLatin1String("queued")) + row.state = QStringLiteral("checking"); + mark(row); +} + +void DownloadsModel::setPhase(const QString& phase) { + if (m_phase == phase) return; + m_phase = phase; + emit phaseChanged(); +} + +void DownloadsModel::finish(const DownloadResult&) { + for (auto& row : m_rows) { + if (isActive(row.state)) { + row.state = QStringLiteral("cancelled"); + row.progress = 0; + m_dirty.insert(row.id); + } + } + m_timer.stop(); + flush(); +} + +void DownloadsModel::setBusy(bool busy) { + if (m_busy == busy) return; + m_busy = busy; + if (busy) { + m_sampledBytes = m_bytes; + m_speedTimer.start(); + } else { + m_speedTimer.stop(); + m_speed = 0; + emit speedChanged(); + } + emit summaryChanged(); +} + +QStringList DownloadsModel::failedIds() const { + QStringList ids; + for (const auto& row : m_rows) { + if (row.state == QLatin1String("failed") || + row.state == QLatin1String("cancelled")) + ids.append(row.id); + } + return ids; +} + +void DownloadsModel::clear() { + if (!m_busy) begin({}, {}); +} + +void DownloadsModel::setFilter(const QString& name) { + if (m_filter == name) return; + m_filter = name; + beginResetModel(); + select(); + endResetModel(); + emit summaryChanged(); +} + +void DownloadsModel::sampleSpeed() { + m_speed = static_cast(m_bytes - m_sampledBytes); + m_sampledBytes = m_bytes; + if (m_history.size() == kHistoryLength) m_history.removeFirst(); + m_history.append(m_speed); + emit speedChanged(); +} + +void DownloadsModel::mark(const Row& row) { + m_dirty.insert(row.id); + if (!m_timer.isActive()) m_timer.start(); +} + +bool DownloadsModel::matches(const Row& row) const { + if (m_filter == QLatin1String("active")) return isActive(row.state); + if (m_filter == QLatin1String("updated")) + return row.state == QLatin1String("updated"); + if (m_filter == QLatin1String("failed")) + return row.state == QLatin1String("failed") || + row.state == QLatin1String("cancelled"); + return true; +} + +void DownloadsModel::select() { + m_visible.clear(); + for (qsizetype pos = 0; pos < m_rows.size(); ++pos) { + if (matches(m_rows[pos])) m_visible.append(pos); + } +} + +void DownloadsModel::recount() { + m_counts.clear(); + for (const auto& row : m_rows) ++m_counts[row.state]; +} + +void DownloadsModel::flush() { + if (m_dirty.isEmpty()) return; + recount(); + if (m_filter == QLatin1String("all")) { + for (qsizetype pos = 0; pos < m_visible.size(); ++pos) { + if (m_dirty.contains(m_rows[m_visible[pos]].id)) { + const auto idx = index(static_cast(pos)); + emit dataChanged(idx, idx); + } + } + } else { + beginResetModel(); + select(); + endResetModel(); + } + m_dirty.clear(); + emit summaryChanged(); +} + +int DownloadsModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_visible.size()); +} + +QVariant DownloadsModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_visible.size()) + return {}; + const auto& row = m_rows[m_visible[index.row()]]; + switch (role) { + case kIdRole: + return row.id; + case kTitleRole: + return row.title; + case kStateRole: + return row.state; + case kProgressRole: + return row.progress; + case kErrorRole: + return row.error; + default: + return {}; + } +} + +QHash DownloadsModel::roleNames() const { + return {{kIdRole, "id"}, + {kTitleRole, "title"}, + {kStateRole, "state"}, + {kProgressRole, "progress"}, + {kErrorRole, "error"}}; +} + +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/model.hpp b/src/ui/plugins/downloads/model.hpp new file mode 100644 index 0000000..7c46411 --- /dev/null +++ b/src/ui/plugins/downloads/model.hpp @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/downloads/types.hpp" + +namespace px::modrim { + +class DownloadsModel final : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(int total READ total NOTIFY summaryChanged) + Q_PROPERTY(int finished READ finished NOTIFY summaryChanged) + Q_PROPERTY(int updated READ updated NOTIFY summaryChanged) + Q_PROPERTY(int unchanged READ unchanged NOTIFY summaryChanged) + Q_PROPERTY(int failed READ failed NOTIFY summaryChanged) + Q_PROPERTY(int active READ active NOTIFY summaryChanged) + Q_PROPERTY(int resolved READ resolved NOTIFY summaryChanged) + Q_PROPERTY(bool busy READ busy NOTIFY summaryChanged) + Q_PROPERTY(QString filter READ filter NOTIFY summaryChanged) + Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged) + Q_PROPERTY(double speed READ speed NOTIFY speedChanged) + Q_PROPERTY(double bytesDone READ bytesDone NOTIFY speedChanged) + Q_PROPERTY(QVariantList speedHistory READ speedHistory NOTIFY speedChanged) + + public: + enum Role { + kIdRole = Qt::UserRole + 1, + kTitleRole, + kStateRole, + kProgressRole, + kErrorRole + }; + static constexpr qsizetype kHistoryLength = 90; + + explicit DownloadsModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void begin(const QStringList& ids, const QHash& titles); + void apply(const DownloadItemStatus& status); + void setTitle(const DownloadItemTitle& item); + void setPhase(const QString& phase); + void finish(const DownloadResult& result); + void setBusy(bool busy); + QStringList failedIds() const; + void flush(); + Q_INVOKABLE void clear(); + Q_INVOKABLE void setFilter(const QString& name); + + int total() const { return static_cast(m_rows.size()); } + int finished() const { return updated() + unchanged() + failed(); } + int updated() const { return m_counts.value(QStringLiteral("updated")); } + int unchanged() const { return m_counts.value(QStringLiteral("unchanged")); } + int failed() const { + return m_counts.value(QStringLiteral("failed")) + + m_counts.value(QStringLiteral("cancelled")); + } + int active() const { + return m_counts.value(QStringLiteral("queued")) + + m_counts.value(QStringLiteral("checking")) + + m_counts.value(QStringLiteral("downloading")); + } + int resolved() const { + return total() - m_counts.value(QStringLiteral("queued")); + } + bool busy() const { return m_busy; } + QString filter() const { return m_filter; } + QString phase() const { return m_phase; } + double speed() const { return m_speed; } + double bytesDone() const { return static_cast(m_bytes); } + QVariantList speedHistory() const { return m_history; } + + signals: + void summaryChanged(); + void speedChanged(); + void phaseChanged(); + + private: + struct Row { + QString id; + QString title; + QString state = QStringLiteral("queued"); + double progress = 0; + QString error; + qint64 bytesDone = 0; + }; + void mark(const Row& row); + bool matches(const Row& row) const; + void select(); + void recount(); + void sampleSpeed(); + QList m_rows; + QHash m_byId; + QList m_visible; + QString m_filter = QStringLiteral("all"); + QString m_phase; + bool m_busy = false; + QHash m_counts; + QSet m_dirty; + QTimer m_timer; + QTimer m_speedTimer; + qint64 m_bytes = 0; + qint64 m_sampledBytes = 0; + double m_speed = 0; + QVariantList m_history; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/plugin.cpp b/src/ui/plugins/downloads/plugin.cpp new file mode 100644 index 0000000..319c24b --- /dev/null +++ b/src/ui/plugins/downloads/plugin.cpp @@ -0,0 +1,13 @@ +#include "ui/plugins/downloads/plugin.hpp" + +#include "ui/plugins/downloads/view.hpp" + +namespace px::modrim { + +DownloadsUiPlugin::DownloadsUiPlugin() + : UiPlugin(QStringLiteral("downloads_ui"), {QStringLiteral("downloads")}) {} +void DownloadsUiPlugin::setup(AppContext& ctx) { + ctx.addRailView(); +} + +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/plugin.hpp b/src/ui/plugins/downloads/plugin.hpp new file mode 100644 index 0000000..af993b2 --- /dev/null +++ b/src/ui/plugins/downloads/plugin.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "ui/context.hpp" + +namespace px::modrim { + +class DownloadsUiPlugin final : public UiPlugin { + public: + DownloadsUiPlugin(); + void setup(AppContext& ctx) override; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.cpp b/src/ui/plugins/downloads/view.cpp new file mode 100644 index 0000000..4611df1 --- /dev/null +++ b/src/ui/plugins/downloads/view.cpp @@ -0,0 +1,68 @@ +#include "ui/plugins/downloads/view.hpp" + +#include +#include + +#include + +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +DownloadsViewPanel::DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), + m_downloads(ctx.downloads()), + m_model(new DownloadsModel(this)) { + auto* qml = new QQuickWidget(engine, this); + qml->setObjectName(QStringLiteral("downloadsView")); + qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsPanel"), + this); + qml->rootContext()->setContextProperty(QStringLiteral("downloadsModel"), + m_model); + qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Downloads.qml"))); + m_root->addWidget(qml, 1); + m_model->setBusy(m_downloads.isDownloading()); + m_batchId = m_downloads.batchStarted.connect([this](const QStringList& ids) { + m_model->begin(ids, m_downloads.namesById(m_ctx.allMods().values())); + }); + m_statusId = m_downloads.downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& status) { m_model->apply(status); }); + m_titleId = m_downloads.downloadItemTitled.connect( + [this](const DownloadItemTitle& item) { m_model->setTitle(item); }); + m_phaseId = m_downloads.downloadPhaseChanged.connect( + [this](const QString& phase) { m_model->setPhase(phase); }); + m_finishedId = m_downloads.downloadFinished.connect( + [this](const DownloadResult& result) { m_model->finish(result); }); + m_busyId = m_downloads.busyChanged.connect( + [this](bool busy) { m_model->setBusy(busy); }); +} + +DownloadsViewPanel::~DownloadsViewPanel() { + // The QML bindings read downloadsModel, which is a child of this panel and + // would otherwise be destroyed before the view that binds to it. + delete findChild(QStringLiteral("downloadsView")); + m_downloads.batchStarted.disconnect(m_batchId); + m_downloads.downloadItemStatusChanged.disconnect(m_statusId); + m_downloads.downloadItemTitled.disconnect(m_titleId); + m_downloads.downloadPhaseChanged.disconnect(m_phaseId); + m_downloads.downloadFinished.disconnect(m_finishedId); + m_downloads.busyChanged.disconnect(m_busyId); +} + +void DownloadsViewPanel::stop() { m_downloads.cancel(); } + +QCoro::QmlTask DownloadsViewPanel::retryFailed() { return retryFailedAsync(); } + +QCoro::Task DownloadsViewPanel::retryFailedAsync() { + auto ids = m_model->failedIds(); + if (ids.isEmpty()) co_return; + auto result = co_await m_downloads.downloadMods(std::move(ids)); + if (!result) + spdlog::warn("downloads retry not started: {}", + result.error().message.toStdString()); +} + +} // namespace px::modrim diff --git a/src/ui/plugins/downloads/view.hpp b/src/ui/plugins/downloads/view.hpp new file mode 100644 index 0000000..b3fb8a9 --- /dev/null +++ b/src/ui/plugins/downloads/view.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include "core/downloads/manager.hpp" +#include "ui/plugins/downloads/model.hpp" +#include "ui/views/base.hpp" + +namespace px::modrim { + +class DownloadsViewPanel final : public BaseViewPanel { + Q_OBJECT + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("downloads"), + QLatin1StringView("download"), + QLatin1StringView("Downloads"), true}; + DownloadsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + ~DownloadsViewPanel() override; + Q_INVOKABLE void stop(); + Q_INVOKABLE QCoro::QmlTask retryFailed(); + + private: + QCoro::Task retryFailedAsync(); + DownloadManager& m_downloads; + DownloadsModel* m_model; + Event::Id m_batchId; + Event::Id m_statusId; + Event::Id m_titleId; + Event::Id m_phaseId; + Event::Id m_finishedId; + Event::Id m_busyId; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.cpp b/src/ui/plugins/organizer/dialogs.cpp new file mode 100644 index 0000000..708e1dc --- /dev/null +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -0,0 +1,152 @@ +#include "ui/plugins/organizer/dialogs.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" + +namespace px::modrim { + +namespace { + +using px::modrim::organizer::FolderNode; +using px::modrim::organizer::kRootId; + +QPushButton* makeButton(const QString& text, QWidget* parent, + const QString& objectName) { + auto* button = new QPushButton(text, parent); + button->setObjectName(objectName); + button->setCursor(Qt::PointingHandCursor); + return button; +} + +void addButtonRow(QVBoxLayout* layout, QWidget* cancel, QWidget* confirm) { + auto* row = new QHBoxLayout; + row->addStretch(1); + row->addWidget(cancel); + row->addWidget(confirm); + layout->addLayout(row); +} + +void addFolder(QTreeWidgetItem* parent, const FolderNode& node, + const QSet& allowed) { + auto* item = new QTreeWidgetItem(parent, QStringList{node.folder.name}); + item->setData(0, Qt::UserRole, node.folder.id); + if (!allowed.contains(node.folder.id)) { + item->setDisabled(true); + } + for (const FolderNode& child : node.children) { + addFolder(item, child, allowed); + } +} + +} // namespace + +FolderNameDialog::FolderNameDialog(const QString& title, const QString& initial, + QWidget* parent, const QString& verb) + : QDialog(parent) { + setWindowTitle(title); + setMinimumWidth(360); + auto* layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(QStringLiteral("Folder name"), this)); + m_edit = new QLineEdit(initial, this); + layout->addWidget(m_edit); + + const QString primaryText = + verb.isEmpty() ? title.section(u' ', 0, 0, QString::SectionSkipEmpty) + : verb; + auto* ok = makeButton(primaryText, this, QStringLiteral("primaryAction")); + ok->setDefault(true); + auto* cancel = + makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); + connect(ok, &QPushButton::clicked, this, &QDialog::accept); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + addButtonRow(layout, cancel, ok); + + auto validate = [ok](const QString& text) { + ok->setEnabled(!text.trimmed().isEmpty()); + }; + connect(m_edit, &QLineEdit::textChanged, this, validate); + connect(m_edit, &QLineEdit::returnPressed, ok, &QPushButton::click); + validate(initial); + m_edit->selectAll(); +} + +QString FolderNameDialog::textValue() const { return m_edit->text().trimmed(); } + +DeleteFolderDialog::DeleteFolderDialog(const QString& name, QWidget* parent) + : QMessageBox(parent) { + setIcon(QMessageBox::Warning); + setWindowTitle(QStringLiteral("Delete Folder")); + setText(QStringLiteral("Delete ‘%1’ and its subfolders?").arg(name)); + setInformativeText(QStringLiteral( + "Their mods become ungrouped. Load order is not affected.")); + setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel); + setDefaultButton(QMessageBox::Cancel); + setEscapeButton(QMessageBox::Cancel); + if (auto* yes = qobject_cast(button(QMessageBox::Yes))) { + yes->setText(QStringLiteral("Delete")); + yes->setObjectName(QStringLiteral("primaryAction")); + } +} + +FolderPickerDialog::FolderPickerDialog(const FolderNode& tree, + const QString& title, + const QSet& allowed, + QWidget* parent, const QString& verb) + : QDialog(parent) { + setWindowTitle(title); + resize(380, 450); + auto* layout = new QVBoxLayout(this); + layout->addWidget(new QLabel(QStringLiteral("Destination folder"), this)); + m_tree = new QTreeWidget(this); + m_tree->setHeaderHidden(true); + m_tree->setRootIsDecorated(true); + layout->addWidget(m_tree); + + auto* top = new QTreeWidgetItem( + m_tree, QStringList{QStringLiteral("Ungrouped (top level)")}); + top->setData(0, Qt::UserRole, kRootId); + if (!allowed.contains(kRootId)) { + top->setDisabled(true); + } + for (const FolderNode& folder : tree.children) { + addFolder(top, folder, allowed); + } + m_tree->expandAll(); + + auto* ok = makeButton(verb, this, QStringLiteral("primaryAction")); + ok->setDefault(true); + ok->setEnabled(false); + auto* cancel = + makeButton(QStringLiteral("Cancel"), this, QStringLiteral("appBtn")); + + auto acceptValid = [this, ok] { + if (ok->isEnabled()) { + accept(); + } + }; + connect(m_tree, &QTreeWidget::currentItemChanged, this, + [ok](QTreeWidgetItem* item) { + ok->setEnabled(item != nullptr && !item->isDisabled()); + }); + connect(m_tree, &QTreeWidget::itemDoubleClicked, this, acceptValid); + connect(ok, &QPushButton::clicked, this, acceptValid); + connect(cancel, &QPushButton::clicked, this, &QDialog::reject); + addButtonRow(layout, cancel, ok); +} + +qint64 FolderPickerDialog::folderId() const { + QTreeWidgetItem* item = m_tree->currentItem(); + Q_ASSERT(item != nullptr); + return item->data(0, Qt::UserRole).toLongLong(); +} + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/dialogs.hpp b/src/ui/plugins/organizer/dialogs.hpp new file mode 100644 index 0000000..82e21f3 --- /dev/null +++ b/src/ui/plugins/organizer/dialogs.hpp @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include + +class QLineEdit; +class QTreeWidget; + +namespace px::modrim::organizer { +struct FolderNode; +} + +namespace px::modrim { + +// Asks for a folder name. Enter confirms; the confirm button is disabled while +// the name is blank. Without `verb`, the first word of `title` names the +// confirm button. +class FolderNameDialog : public QDialog { + public: + FolderNameDialog(const QString& title, const QString& initial, + QWidget* parent, const QString& verb = {}); + + // The entered name with surrounding whitespace removed. + QString textValue() const; + + private: + QLineEdit* m_edit = nullptr; +}; + +// Confirms deleting a folder. Its mods become ungrouped; load order is +// unchanged. Yes deletes; Cancel and Escape keep the folder. +class DeleteFolderDialog : public QMessageBox { + public: + DeleteFolderDialog(const QString& name, QWidget* parent); +}; + +// Picks a destination folder from `tree`'s children, with a top-level +// "Ungrouped" entry for ROOT_ID. Folders not in `allowed` are shown but cannot +// be chosen. +class FolderPickerDialog : public QDialog { + public: + FolderPickerDialog(const px::modrim::organizer::FolderNode& tree, + const QString& title, const QSet& allowed, + QWidget* parent, + const QString& verb = QStringLiteral("Move")); + + // The id of the highlighted folder. Only meaningful once the confirm button + // is enabled. + qint64 folderId() const; + + private: + QTreeWidget* m_tree = nullptr; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.cpp b/src/ui/plugins/organizer/filter_model.cpp new file mode 100644 index 0000000..4daaa52 --- /dev/null +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -0,0 +1,81 @@ +#include "ui/plugins/organizer/filter_model.hpp" + +namespace px::modrim { + +OrganizerFilterModel::OrganizerFilterModel(QObject* parent) + : QAbstractListModel(parent) {} + +int OrganizerFilterModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_items.size()); +} + +QVariant OrganizerFilterModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto& entry = m_items[index.row()]; + const bool tag = entry.tagId.has_value() || entry.key.startsWith("tag:"); + switch (role) { + case Qt::DisplayRole: + case kLabelRole: + return entry.label; + case kCountRole: + return entry.count; + case kKeyRole: + return entry.key; + case kSectionRole: + if (tag) return QStringLiteral("Tags"); + if (entry.providerId || entry.key.startsWith("provider:")) + return QStringLiteral("Providers"); + return QStringLiteral("Status"); + case kIconRole: + if (tag) return QStringLiteral("tag"); + if (entry.key == "all") return QStringLiteral("grid"); + if (entry.key == "active") return QStringLiteral("check-circle"); + if (entry.key == "inactive") return QStringLiteral("ban"); + if (entry.providerId == "steam" || entry.providerId == "downloaded") + return QStringLiteral("steam"); + if (entry.providerId == "core") return QStringLiteral("grid"); + if (entry.providerId == "git") return QStringLiteral("git"); + return QStringLiteral("folder"); + default: + return {}; + } +} + +QHash OrganizerFilterModel::roleNames() const { + return {{Qt::DisplayRole, "filterLabel"}, + {kCountRole, "count"}, + {kIconRole, "iconName"}, + {kSectionRole, "sectionName"}, + {kKeyRole, "key"}}; +} + +void OrganizerFilterModel::update(const QList& entries) { + bool sameKeys = entries.size() == m_items.size(); + for (qsizetype i = 0; sameKeys && i < entries.size(); ++i) + sameKeys = entries[i].key == m_items[i].key; + if (!sameKeys) { + beginResetModel(); + m_items = entries; + endResetModel(); + } else { + m_items = entries; + if (!m_items.isEmpty()) + emit dataChanged(index(0), index(static_cast(m_items.size()) - 1), + {kCountRole, kLabelRole, Qt::DisplayRole}); + } +} + +std::optional OrganizerFilterModel::keyAt(int row) const { + if (row < 0 || row >= m_items.size()) return std::nullopt; + return m_items[row].key; +} + +organizer::TreeFilter OrganizerFilterModel::forKey(const QString& key) const { + for (const auto& entry : m_items) + if (entry.key == key) return entry; + if (!m_items.isEmpty()) return m_items.first(); + return {QStringLiteral("all"), QStringLiteral("All"), 0}; +} + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/filter_model.hpp b/src/ui/plugins/organizer/filter_model.hpp new file mode 100644 index 0000000..91f7cf3 --- /dev/null +++ b/src/ui/plugins/organizer/filter_model.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "core/organizer/resolve.hpp" + +namespace px::modrim { + +class OrganizerFilterModel : public QAbstractListModel { + Q_OBJECT + public: + enum Role { + kLabelRole = Qt::UserRole + 1, + kCountRole, + kIconRole, + kSectionRole, + kKeyRole + }; + Q_ENUM(Role) + explicit OrganizerFilterModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void update(const QList& entries); + std::optional keyAt(int row) const; + organizer::TreeFilter forKey(const QString& key) const; + + private: + QList m_items; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/plugin.hpp b/src/ui/plugins/organizer/plugin.hpp new file mode 100644 index 0000000..c121db3 --- /dev/null +++ b/src/ui/plugins/organizer/plugin.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "ui/context.hpp" +#include "ui/plugins/organizer/view.hpp" + +namespace px::modrim { + +class OrganizerUiPlugin final : public UiPlugin { + public: + OrganizerUiPlugin() + : UiPlugin(QStringLiteral("organizer_ui"), + {QStringLiteral("organizer")}) {} + void setup(AppContext& ctx) override { + ctx.addRailView(0); + } +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/tree_model.cpp b/src/ui/plugins/organizer/tree_model.cpp new file mode 100644 index 0000000..52b5233 --- /dev/null +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -0,0 +1,393 @@ +#include "ui/plugins/organizer/tree_model.hpp" + +#include + +#include "ui/models/mod_list_model.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +namespace { +using namespace organizer; + +QString providerIcon(const QString& id) { + if (id == "steam" || id == "downloaded") return QStringLiteral("steam"); + if (id == "core") return QStringLiteral("grid"); + if (id == "git") return QStringLiteral("git"); + return QStringLiteral("folder"); +} + +int checkState(CheckState state) { + switch (state) { + case CheckState::kOn: + return Qt::Checked; + case CheckState::kPartial: + return Qt::PartiallyChecked; + case CheckState::kOff: + return Qt::Unchecked; + } + return Qt::Unchecked; +} + +std::unique_ptr build(const FolderNode& root, const ModsByUuid& mods, + bool filtering) { + auto top = std::make_unique(); + top->kind = QStringLiteral("root"); + auto addMods = [&](TreeNode* parent, const QList& leaves) { + for (const auto& leaf : leaves) { + auto item = std::make_unique(); + item->kind = QStringLiteral("mod"); + item->key = "m:" + leaf.uuid; + item->leaf = &leaf; + const auto it = mods.constFind(leaf.uuid); + if (it != mods.cend() && *it) { + const auto& mod = **it; + item->providerId = mod.providerId; + item->providerLabel = providerLabel( + mod.providerId, modPackageId(mod).value_or(QString{})); + if (const auto* about = dynamic_cast(&mod)) + item->modVersion = about->modVersion; + } + parent->add(std::move(item)); + } + }; + auto addFolder = [&](auto&& self, TreeNode* parent, + const FolderNode& folder) -> void { + auto item = std::make_unique(); + item->kind = QStringLiteral("folder"); + item->key = "f:" + QString::number(folder.folder.id); + item->folder = &folder; + TreeNode* ptr = item.get(); + parent->add(std::move(item)); + for (const auto& child : folder.children) self(self, ptr, child); + addMods(ptr, folder.mods); + }; + for (const auto& child : root.children) + addFolder(addFolder, top.get(), child); + if (!root.mods.isEmpty() || !filtering) { + auto item = std::make_unique(); + item->kind = QStringLiteral("ungrouped"); + item->key = kUngroupedKey; + item->folder = &root; + addMods(item.get(), root.mods); + top->add(std::move(item)); + } + return top; +} + +bool sameShape(const TreeNode& a, const TreeNode& b) { + if (a.key != b.key || a.children.size() != b.children.size()) return false; + for (size_t i = 0; i < a.children.size(); ++i) + if (!sameShape(*a.children[i], *b.children[i])) return false; + return true; +} + +void copyPayload(TreeNode& dst, TreeNode& src) { + dst.folder = src.folder; + dst.leaf = src.leaf; + dst.providerId = std::move(src.providerId); + dst.providerLabel = std::move(src.providerLabel); + dst.modVersion = std::move(src.modVersion); + for (size_t i = 0; i < dst.children.size(); ++i) + copyPayload(*dst.children[i], *src.children[i]); +} + +const QList kDiagnosticRoles{ + ModTreeModel::kHasErrorRole, ModTreeModel::kErrorTooltipRole, + ModTreeModel::kHasWarningRole, ModTreeModel::kWarningTooltipRole}; +} // namespace + +void TreeNode::add(std::unique_ptr child) { + child->parent = this; + child->row = static_cast(children.size()); + children.push_back(std::move(child)); +} + +QList TreeNode::walk() const { + QList result; + auto visit = [&](auto&& self, const TreeNode& current) -> void { + for (const auto& child : current.children) { + result.append(child.get()); + self(self, *child); + } + }; + visit(visit, *this); + return result; +} +TreeNode::TreeNode(QString kind, QString key, TreeNode* parent, + const organizer::FolderNode* folder, + const organizer::ModLeaf* leaf, QString providerId) + : kind(std::move(kind)), + key(std::move(key)), + parent(parent), + folder(folder), + leaf(leaf), + providerId(std::move(providerId)) {} + +qint64 TreeNode::folderId() const { + if (kind == "folder" && folder) return folder->folder.id; + if (kind == "mod" && parent) return parent->folderId(); + return organizer::kRootId; +} + +bool TreeNode::isContainer() const { + return kind == "folder" || kind == "ungrouped"; +} + +ModTreeModel::ModTreeModel(SummaryFor summaryFor, QObject* parent) + : QAbstractItemModel(parent), + m_summaryFor(std::move(summaryFor)), + m_root(std::make_unique()) { + m_root->kind = QStringLiteral("root"); +} + +TreeNode* ModTreeModel::node(const QModelIndex& index) const { + return index.isValid() ? static_cast(index.internalPointer()) + : m_root.get(); +} + +QModelIndex ModTreeModel::index(int row, int column, + const QModelIndex& parent) const { + const auto* current = node(parent); + if (column != 0 || row < 0 || + static_cast(row) >= current->children.size()) + return {}; + return createIndex(row, 0, current->children[row].get()); +} + +QModelIndex ModTreeModel::parent(const QModelIndex& child) const { + if (!child.isValid()) return {}; + const auto* up = node(child)->parent; + return !up || up == m_root.get() ? QModelIndex{} : indexOf(up); +} + +int ModTreeModel::rowCount(const QModelIndex& parent) const { + return parent.column() > 0 ? 0 + : static_cast(node(parent)->children.size()); +} +int ModTreeModel::columnCount(const QModelIndex&) const { return 1; } + +QVariant ModTreeModel::data(const QModelIndex& index, int role) const { + if (!index.isValid()) return {}; + const auto* item = node(index); + if (role == Qt::DisplayRole) { + if (item->kind == "ungrouped") return QStringLiteral("Ungrouped"); + if (item->kind == "folder" && item->folder) + return item->folder->folder.name; + return item->leaf ? item->leaf->name : QString{}; + } + if (role == kKindRole) return item->kind; + if (role == kKeyRole) return item->key; + if (role == kSelectedRole) return m_selected.contains(item->key); + if (role == kFilteringRole) return m_filtering; + if (item->kind != "mod") { + if (!item->folder) return {}; + const auto& folder = *item->folder; + const bool own = item->kind == "ungrouped"; + switch (role) { + case kCheckStateRole: + return checkState(own ? folder.ownCheck() : folder.check); + case kEnabledCountRole: + return own ? folder.ownEnabled : folder.enabled; + case kTotalCountRole: + return own ? folder.ownTotal : folder.total; + case kVisibleCountRole: + return own ? static_cast(folder.mods.size()) : folder.visibleTotal; + case kHasRuleRole: + return folder.hasRules && !own; + default: + return {}; + } + } + if (!item->leaf) return {}; + const auto& leaf = *item->leaf; + switch (role) { + case kUuidRole: + return leaf.uuid; + case kPackageIdRole: + return leaf.packageId.value_or(QString{}); + case kCheckStateRole: + return leaf.enabled ? Qt::Checked : Qt::Unchecked; + case kProviderLabelRole: + return item->providerLabel; + case kModVersionRole: + return item->modVersion; + case kProviderIconRole: + return providerIcon(item->providerId); + case kProviderColorRole: { + const auto it = m_providerColors.constFind(item->providerId); + return it != m_providerColors.cend() ? *it : paletteValue(u"TEXT_DIM"); + } + case kCanPlaceRole: + return leaf.packageId.has_value(); + case kTagsRole: + return m_tagRows.value(leaf.packageId.value_or(QString{})); + default: + break; + } + if (kDiagnosticRoles.contains(role)) { + const auto summary = leaf.enabled ? m_summaryFor(leaf.uuid) : std::nullopt; + if (role == kHasErrorRole) return summary && summary->hasErrors; + if (role == kHasWarningRole) return summary && summary->hasWarnings; + if (role == kErrorTooltipRole) + return summary ? summary->errorTooltip : QString{}; + return summary ? summary->warningTooltip : QString{}; + } + return {}; +} + +QHash ModTreeModel::roleNames() const { + return {{Qt::DisplayRole, "name"}, + {kKindRole, "kind"}, + {kKeyRole, "key"}, + {kUuidRole, "uuid"}, + {kPackageIdRole, "packageId"}, + {kCheckStateRole, "checkState"}, + {kEnabledCountRole, "enabledCount"}, + {kTotalCountRole, "totalCount"}, + {kVisibleCountRole, "visibleCount"}, + {kFilteringRole, "filtering"}, + {kHasRuleRole, "hasRule"}, + {kProviderIconRole, "providerIcon"}, + {kProviderLabelRole, "providerLabel"}, + {kModVersionRole, "modVersion"}, + {kProviderColorRole, "providerColor"}, + {kHasErrorRole, "hasError"}, + {kErrorTooltipRole, "errorTooltip"}, + {kHasWarningRole, "hasWarning"}, + {kWarningTooltipRole, "warningTooltip"}, + {kCanPlaceRole, "canPlace"}, + {kSelectedRole, "selected"}, + {kTagsRole, "tags"}}; +} + +bool ModTreeModel::filtering() const { return m_filtering; } + +void ModTreeModel::setTree(const organizer::FolderNode& root, + const organizer::ModsByUuid& mods, + const QHash& providerColors, + bool filtering, + const QHash& tags) { + auto payload = std::make_unique(root); + auto fresh = build(*payload, mods, filtering); + m_providerColors = providerColors; + m_tagRows.clear(); + for (const auto* item : fresh->walk()) { + if (!item->leaf || !item->leaf->packageId || + item->leaf->packageId->isEmpty() || item->leaf->tagIds.isEmpty()) + continue; + QList ids(item->leaf->tagIds.begin(), item->leaf->tagIds.end()); + std::sort(ids.begin(), ids.end()); + QVariantList rows; + for (qint64 id : ids) { + const auto tag = tags.constFind(id); + if (tag != tags.cend()) + rows.append(QVariantMap{{"name", tag->name}, {"color", tag->color}}); + } + m_tagRows.insert(*item->leaf->packageId, rows); + } + if (m_filtering == filtering && sameShape(*m_root, *fresh)) { + copyPayload(*m_root, *fresh); + m_payload = std::move(payload); + emitAllChanged(m_root.get()); + } else { + beginResetModel(); + m_root = std::move(fresh); + m_payload = std::move(payload); + m_filtering = filtering; + endResetModel(); + } + QSet live; + for (const auto* item : m_root->walk()) live.insert(item->key); + m_selected.intersect(live); +} + +QModelIndex ModTreeModel::indexOf(const TreeNode* item) const { + return item == m_root.get() + ? QModelIndex{} + : createIndex(item->row, 0, const_cast(item)); +} + +void ModTreeModel::emitAllChanged(TreeNode* item, const QList& roles) { + if (item->children.empty()) return; + const auto parent = indexOf(item); + emit dataChanged( + index(0, 0, parent), + index(static_cast(item->children.size()) - 1, 0, parent), roles); + for (const auto& child : item->children) emitAllChanged(child.get(), roles); +} + +void ModTreeModel::refreshDiagnostics() { + emitAllChanged(m_root.get(), kDiagnosticRoles); +} +TreeNode* ModTreeModel::nodeAt(const QModelIndex& index) const { + return index.isValid() ? node(index) : nullptr; +} +QModelIndex ModTreeModel::indexForKey(const QString& key) const { + auto find = [&](auto&& self, const TreeNode* parent) -> QModelIndex { + for (const auto& child : parent->children) { + if (child->key == key) return indexOf(child.get()); + const auto found = self(self, child.get()); + if (found.isValid()) return found; + } + return {}; + }; + return find(find, m_root.get()); +} +QModelIndex ModTreeModel::indexForUuid(const QString& uuid) const { + return indexForKey("m:" + uuid); +} +QList ModTreeModel::expandedIndexes( + const QModelIndex& parent) const { + QList result; + auto visit = [&](auto&& self, const TreeNode* item) -> void { + for (const auto& child : item->children) { + if (child->isContainer() && + (m_filtering || + (child->folder && !child->folder->folder.collapsed))) { + result.append(indexOf(child.get())); + self(self, child.get()); + } + } + }; + visit(visit, node(parent)); + return result; +} +QList ModTreeModel::selectedNodes() const { + QList result; + for (auto* item : m_root->walk()) + if (m_selected.contains(item->key)) result.append(item); + return result; +} +bool ModTreeModel::isSelected(const QModelIndex& index) const { + const auto* item = nodeAt(index); + return item && m_selected.contains(item->key); +} +void ModTreeModel::select(const QList& indexes, + const QString& mode) { + QSet keys; + for (const auto& index : indexes) + if (const auto* item = nodeAt(index)) keys.insert(item->key); + const auto before = m_selected; + if (mode == "replace") + m_selected = keys; + else if (mode == "toggle") + m_selected = (m_selected - keys) | (keys - m_selected); + else + m_selected.unite(keys); + notifySelection((before - m_selected) | (m_selected - before)); +} +void ModTreeModel::clearSelection() { + const auto before = m_selected; + m_selected.clear(); + notifySelection(before); +} +void ModTreeModel::notifySelection(const QSet& changed) { + if (changed.isEmpty()) return; + for (const auto* item : m_root->walk()) + if (changed.contains(item->key)) { + const auto index = indexOf(item); + emit dataChanged(index, index, {kSelectedRole}); + } +} + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/tree_model.hpp b/src/ui/plugins/organizer/tree_model.hpp new file mode 100644 index 0000000..2a9cf8e --- /dev/null +++ b/src/ui/plugins/organizer/tree_model.hpp @@ -0,0 +1,107 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "core/models/view/diagnostics.hpp" +#include "core/organizer/resolve.hpp" + +namespace px::modrim { + +inline const QString kUngroupedKey = QStringLiteral("ungrouped"); + +struct TreeNode { + QString kind; + QString key; + TreeNode* parent = nullptr; + const organizer::FolderNode* folder = nullptr; + const organizer::ModLeaf* leaf = nullptr; + QString providerId; + QString providerLabel; + QString modVersion; + int row = 0; + std::vector> children; + + void add(std::unique_ptr child); + QList walk() const; + qint64 folderId() const; + TreeNode(QString kind = QStringLiteral("root"), QString key = {}, + TreeNode* parent = nullptr, + const organizer::FolderNode* folder = nullptr, + const organizer::ModLeaf* leaf = nullptr, QString providerId = {}); + bool isContainer() const; +}; + +class ModTreeModel : public QAbstractItemModel { + Q_OBJECT + public: + enum Role { + kKindRole = Qt::UserRole + 1, + kKeyRole, + kUuidRole, + kPackageIdRole, + kCheckStateRole, + kEnabledCountRole, + kTotalCountRole, + kVisibleCountRole, + kFilteringRole, + kHasRuleRole, + kProviderIconRole, + kProviderColorRole, + kHasErrorRole, + kErrorTooltipRole, + kCanPlaceRole, + kSelectedRole, + kHasWarningRole, + kWarningTooltipRole, + kTagsRole, + kProviderLabelRole, + kModVersionRole + }; + Q_ENUM(Role) + using SummaryFor = + std::function(const QString&)>; + explicit ModTreeModel(SummaryFor summaryFor, QObject* parent = nullptr); + QModelIndex index(int row, int column, + const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + bool filtering() const; + void setTree(const organizer::FolderNode& root, + const organizer::ModsByUuid& mods, + const QHash& providerColors, bool filtering, + const QHash& tags = {}); + void refreshDiagnostics(); + TreeNode* nodeAt(const QModelIndex& index) const; + Q_INVOKABLE QModelIndex indexForKey(const QString& key) const; + QModelIndex indexForUuid(const QString& uuid) const; + QList expandedIndexes(const QModelIndex& parent = {}) const; + QList selectedNodes() const; + bool isSelected(const QModelIndex& index) const; + void select(const QList& indexes, + const QString& mode = QStringLiteral("replace")); + void clearSelection(); + + private: + TreeNode* node(const QModelIndex& index) const; + QModelIndex indexOf(const TreeNode* node) const; + void emitAllChanged(TreeNode* node, const QList& roles = {}); + void notifySelection(const QSet& changed); + SummaryFor m_summaryFor; + std::unique_ptr m_root; + std::unique_ptr m_payload; + bool m_filtering = false; + QHash m_providerColors; + QHash m_tagRows; + QSet m_selected; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/view.cpp b/src/ui/plugins/organizer/view.cpp new file mode 100644 index 0000000..40b6024 --- /dev/null +++ b/src/ui/plugins/organizer/view.cpp @@ -0,0 +1,866 @@ +#include "ui/plugins/organizer/view.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "ui/components/dialog_chrome.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/components/filter_sidebar.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/mod_selection.hpp" +#include "ui/panels/mod_info_panel.hpp" +#include "ui/plugins/organizer/dialogs.hpp" +#include "ui/plugins/organizer/filter_model.hpp" +#include "ui/plugins/organizer/tree_model.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +using namespace organizer; +namespace { +QString plural(qsizetype count, const QString& word) { + return QStringLiteral("%1 %2%3").arg(count).arg( + word, count == 1 ? QString{} : QStringLiteral("s")); +} +OrganizerService& organizerService(CoreContext& ctx) { + auto* service = dynamic_cast( + ctx.plugins().get(QStringLiteral("organizer"))); + Q_ASSERT(service); + return *service; +} +QPushButton* button(const QString& text, QWidget* parent, QHBoxLayout* layout) { + auto* result = new QPushButton(text, parent); + result->setObjectName(QStringLiteral("appBtn")); + result->setCursor(Qt::PointingHandCursor); + layout->addWidget(result); + return result; +} +} // namespace + +OrganizerViewPanel::OrganizerViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app), + m_service(organizerService(ctx)), + m_model(new ModTreeModel( + [&ctx](const QString& uuid) { + return ctx.diagnosticsService().summaryFor(uuid); + }, + this)), + m_filters(new OrganizerFilterModel(this)) { + auto* content = new QWidget(this); + content->setObjectName(QStringLiteral("contentArea")); + auto* row = new QHBoxLayout(content); + row->setContentsMargins(0, 0, 0, 0); + row->setSpacing(0); + auto* sidebar = new FilterSidebar(engine, m_filters, content); + sidebar->setFixedWidth(kSidebarWidth); + row->addWidget(sidebar); + connect(sidebar, &FilterSidebar::activated, this, [this](int index) { + const auto key = m_filters->keyAt(index); + if (key && *key != m_filterKey) { + m_filterKey = *key; + invalidate(); + } + }); + auto* main = new QWidget(content); + main->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); + auto* layout = new QVBoxLayout(main); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + auto* toolbar = new QWidget(main); + toolbar->setObjectName(QStringLiteral("searchBox")); + toolbar->setFixedHeight(56); + auto* tools = new QHBoxLayout(toolbar); + tools->setContentsMargins(16, 8, 16, 8); + m_search = new QLineEdit(toolbar); + m_search->setPlaceholderText( + QStringLiteral("Search mods, package ID, author…")); + m_search->setClearButtonEnabled(true); + tools->addWidget(m_search, 1); + auto* create = button(QStringLiteral("New folder"), toolbar, tools); + create->setObjectName(QStringLiteral("primaryAction")); + connect(create, &QPushButton::clicked, this, &OrganizerViewPanel::newFolder); + auto* rules = button(QStringLiteral("Auto-folder rules"), toolbar, tools); + rules->setObjectName(QStringLiteral("organizerRulesButton")); + connect(rules, &QPushButton::clicked, this, &OrganizerViewPanel::openRules); + connect(button(QStringLiteral("Expand all"), toolbar, tools), + &QPushButton::clicked, this, [this] { treeCommand("expandAll"); }); + connect(button(QStringLiteral("Collapse all"), toolbar, tools), + &QPushButton::clicked, this, [this] { treeCommand("collapseAll"); }); + layout->addWidget(toolbar); + + m_qml = new QQuickWidget(engine, main); + m_qml->setObjectName(QStringLiteral("organizerTree")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(u"ELEVATE_1")); + m_qml->rootContext()->setContextProperty(QStringLiteral("organizerPanel"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("modTreeModel"), + m_model); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/ModTree.qml"))); + m_rulesDialog = new QDialog( + this, Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | + Qt::WindowCloseButtonHint | Qt::WindowMinMaxButtonsHint); + m_rulesDialog->setObjectName(QStringLiteral("organizerRuleEditorWindow")); + m_rulesDialog->setWindowTitle(QStringLiteral("Auto-Folder Rules")); + m_rulesDialog->setWindowModality(Qt::WindowModal); + m_rulesDialog->setMinimumSize(720, 520); + m_rulesDialog->setSizeGripEnabled(true); + auto* rulesLayout = new QVBoxLayout(m_rulesDialog); + rulesLayout->setContentsMargins(0, 0, 0, 0); + m_rulesQml = new QQuickWidget(engine, m_rulesDialog); + m_rulesQml->setObjectName(QStringLiteral("organizerRuleEditorQuickWidget")); + m_rulesQml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_rulesQml->setClearColor(paletteColor(u"ELEVATE_2")); + m_rulesQml->rootContext()->setContextProperty( + QStringLiteral("organizerPanel"), this); + m_rulesQml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/RuleEditor.qml"))); + rulesLayout->addWidget(m_rulesQml); + installDialogChrome(m_rulesDialog); + connect(m_rulesDialog, &QDialog::finished, this, [this] { + if (auto* root = m_rulesQml->rootObject()) + QMetaObject::invokeMethod(root, "closeEditor"); + }); + auto* hint = new QWidget(main); + hint->setObjectName(QStringLiteral("organizerHint")); + auto* hints = new QHBoxLayout(hint); + hints->setContentsMargins(12, 5, 12, 5); + auto* hintText = new QLabel( + QStringLiteral("Folders and tags are for browsing only. Edit load order " + "in Mods; checkboxes enable or disable the same mods."), + hint); + hintText->setWordWrap(true); + hints->addWidget(hintText, 1); + auto* dismiss = button(QStringLiteral("×"), hint, hints); + dismiss->setToolTip(QStringLiteral("Dismiss")); + connect(dismiss, &QPushButton::clicked, hint, &QWidget::hide); + layout->addWidget(hint); + layout->addWidget(m_qml, 1); + m_selectionBar = new QWidget(main); + auto* bar = new QHBoxLayout(m_selectionBar); + bar->setContentsMargins(12, 6, 12, 6); + m_selectionLabel = new QLabel(m_selectionBar); + bar->addWidget(m_selectionLabel); + const QList> actions{ + {QStringLiteral("Enable"), QStringLiteral("enable")}, + {QStringLiteral("Disable"), QStringLiteral("disable")}, + {QStringLiteral("Move to folder…"), QStringLiteral("move")}, + {QStringLiteral("Manage tags…"), QStringLiteral("tags")}, + {QStringLiteral("Create folder from selection"), + QStringLiteral("create")}, + {QStringLiteral("Update"), QStringLiteral("update")}}; + for (const auto& [label, action] : actions) { + auto* item = button(label, m_selectionBar, bar); + if (action == QStringLiteral("update")) m_updateButton = item; + connect(item, &QPushButton::clicked, this, + [this, action] { selectionAction(action); }); + } + m_selectionBar->hide(); + layout->addWidget(m_selectionBar); + row->addWidget(main, 3); + m_info = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); + m_info->setObjectName(QStringLiteral("modInfoPanel")); + m_info->setMinimumWidth(300); + row->addWidget(m_info, 2); + m_selection = std::make_unique(ctx, *m_info); + connect(m_info, &ModInfoPanel::modRequested, this, + [this](const QString& uuid) { + const auto index = m_model->indexForUuid(uuid); + if (index.isValid()) selectSingle(index); + }); + m_root->addWidget(content, 1); + m_status = new QLabel(this); + m_status->setObjectName(QStringLiteral("organizerStatus")); + m_root->addWidget(m_status); + m_menu = new QMenu(this); + m_menu->setToolTipsVisible(true); + m_searchTimer = new QTimer(this); + m_searchTimer->setSingleShot(true); + m_searchTimer->setInterval(220); + connect(m_search, &QLineEdit::textChanged, this, + [this] { m_searchTimer->start(); }); + connect(m_searchTimer, &QTimer::timeout, this, [this] { + if (m_searchText != m_search->text()) { + m_searchText = m_search->text(); + invalidate(); + } + }); + connect(m_model, &QAbstractItemModel::modelReset, this, + [this] { m_selectionAnchor = QPersistentModelIndex{}; }); + m_changedId = m_service.changed.connect([this](const auto&) { + invalidate(); + Q_EMIT editorChanged(); + }); + m_activeId = + ctx.activeStateChanged().connect([this](const auto&) { invalidate(); }); + m_modsId = ctx.modService().modsChanged.connect( + [this](const auto&) { invalidate(); }); + m_diagnosticsId = ctx.diagnosticsService().diagnosticsSummaryChanged.connect( + [this](const auto&) { + m_model->refreshDiagnostics(); + if (!m_selectedUuid.isEmpty()) + m_info->setIssues( + m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); + }); + m_busyId = ctx.downloads().busyChanged.connect([this](bool) { syncInfo(); }); +} + +OrganizerViewPanel::~OrganizerViewPanel() { + m_service.changed.disconnect(m_changedId); + m_ctx.activeStateChanged().disconnect(m_activeId); + m_ctx.modService().modsChanged.disconnect(m_modsId); + m_ctx.diagnosticsService().diagnosticsSummaryChanged.disconnect( + m_diagnosticsId); + m_ctx.downloads().busyChanged.disconnect(m_busyId); +} +bool OrganizerViewPanel::ready() const { return m_service.ready(); } +void OrganizerViewPanel::showEvent(QShowEvent* event) { + BaseViewPanel::showEvent(event); + if (m_dirty) rebuild(); + Q_EMIT readyChanged(); + if (auto* root = m_rulesQml->rootObject()) + QMetaObject::invokeMethod(root, "schedulePreview"); +} +void OrganizerViewPanel::invalidate() { + m_dirty = true; + if (isVisible()) rebuild(); +} +void OrganizerViewPanel::rebuild() { + if (!ready()) return; + m_dirty = false; + m_filters->update(m_service.treeFilters()); + const auto query = m_filters->forKey(m_filterKey).query(m_searchText); + const auto tree = m_service.tree(query); + m_model->setTree(tree, m_ctx.allMods(), m_ctx.modService().providerColors(), + !query.isEmpty(), m_service.state().tags); + const auto ungrouped = + query.isEmpty() ? tree.mods.size() : m_service.tree().mods.size(); + m_status->setText( + QStringLiteral("%1 · %2 active · %3 · %4 ungrouped") + .arg(plural(m_ctx.allMods().size(), QStringLiteral("mod"))) + .arg(m_ctx.activeUuids().size()) + .arg(plural(m_service.state().folders.size() - 1, + QStringLiteral("folder"))) + .arg(ungrouped)); + syncInfo(true); + Q_EMIT readyChanged(); +} +QStringList OrganizerViewPanel::selectedPackageIds() const { + QStringList result; + for (const auto* node : m_model->selectedNodes()) + if (node->leaf && node->leaf->packageId && + !result.contains(*node->leaf->packageId)) + result.append(*node->leaf->packageId); + return result; +} +QStringList OrganizerViewPanel::selectedUuids() const { + QStringList result; + for (const auto* node : m_model->selectedNodes()) + if (node->leaf) result.append(node->leaf->uuid); + return result; +} +void OrganizerViewPanel::syncInfo(bool force) { + const auto nodes = m_model->selectedNodes(); + const auto uuids = selectedUuids(); + Q_EMIT editorChanged(); + m_selectionLabel->setText(plural(uuids.size(), QStringLiteral("mod")) + + QStringLiteral(" selected")); + m_selectionBar->setVisible(uuids.size() > 1); + const auto state = updateState(m_ctx, uuids); + m_updateButton->setVisible(m_ctx.downloads().available()); + m_updateButton->setText(state.label); + m_updateButton->setEnabled(state.enabled); + m_updateButton->setToolTip(state.tooltip); + const QString uuid = nodes.size() == 1 && nodes.front()->leaf + ? nodes.front()->leaf->uuid + : QString{}; + if (uuid == m_selectedUuid && !force) return; + m_selectedUuid = uuid; + if (uuid.isEmpty()) + m_selection->clear(); + else + m_selection->show(uuid); +} +int OrganizerViewPanel::selectedTagCount() const { + return selectedPackageIds().size(); +} +QVariantList OrganizerViewPanel::tagRows() const { + QVariantList result; + if (!ready()) return result; + auto tags = m_service.state().tags.values(); + std::ranges::sort(tags, [](const Tag& a, const Tag& b) { + return a.name.toCaseFolded() < b.name.toCaseFolded(); + }); + const auto pids = selectedPackageIds(); + for (const auto& tag : tags) { + int matched = 0; + for (const auto& pid : pids) + if (m_service.state().modTags.value(pid).contains(tag.id)) ++matched; + result.append(QVariantMap{ + {QStringLiteral("id"), tag.id}, + {QStringLiteral("name"), tag.name}, + {QStringLiteral("color"), tag.color}, + {QStringLiteral("checkState"), + matched > 0 && matched < pids.size() ? 2 : int(matched > 0)}}); + } + return result; +} +QVariantList OrganizerViewPanel::ruleRows() const { + QVariantList result; + if (!ready()) return result; + for (const auto& rule : m_service.state().rules) + result.append(QVariantMap{ + {QStringLiteral("field"), QString(ruleFieldName(rule.field))}, + {QStringLiteral("op"), QString(ruleOpName(rule.op))}, + {QStringLiteral("pattern"), rule.pattern}, + {QStringLiteral("folder_id"), rule.folderId}}); + return result; +} +QVariantList OrganizerViewPanel::ruleFolders() const { + QVariantList result; + if (!ready()) return result; + const auto& folders = m_service.state().folders; + QList> paths; + paths.reserve(folders.size() - 1); + for (const auto& folder : folders) { + if (folder.id == kRootId) continue; + QStringList names; + qint64 id = folder.id; + while (id != kRootId) { + const auto& ancestor = *folders.constFind(id); + names.prepend(ancestor.name); + id = *ancestor.parentId; + } + paths.append({names.join(QStringLiteral(" / ")), folder.id}); + } + std::ranges::sort(paths, [](const auto& a, const auto& b) { + return QString::compare(a.first, b.first, Qt::CaseInsensitive) < 0; + }); + result.reserve(paths.size()); + for (const auto& [name, id] : paths) + result.append(QVariantMap{{QStringLiteral("id"), id}, + {QStringLiteral("name"), name}}); + return result; +} +Result> OrganizerViewPanel::ruleSpecs( + const QVariantList& rows) const { + QList specs; + specs.reserve(rows.size()); + for (const auto& value : rows) { + const auto row = value.toMap(); + const auto field = + parseRuleField(row.value(QStringLiteral("field")).toString()); + const auto op = parseRuleOp(row.value(QStringLiteral("op")).toString()); + if (!field || !op) + return std::unexpected( + Error{QStringLiteral("Invalid rule field or operator")}); + specs.append(RuleSpec{*field, *op, + row.value(QStringLiteral("pattern")).toString(), + row.value(QStringLiteral("folder_id")).toLongLong()}); + } + return specs; +} +QCoro::QmlTask OrganizerViewPanel::requestRulePreview(const QVariantList& rows, + int revision) { + return calculateRulePreview(rows, revision); +} +QCoro::Task OrganizerViewPanel::calculateRulePreview(QVariantList rows, + int revision) { + if (!ready()) co_return; + const auto specs = ruleSpecs(rows); + if (!specs) co_return; + const QPointer alive(this); + auto future = QtConcurrent::run( + [state = m_service.state(), mods = m_ctx.allMods(), specs = *specs] { + return previewRuleMatches(state, mods, specs); + }); + const auto preview = co_await future; + if (!alive) co_return; + QVariantList counts; + counts.reserve(preview.counts.size()); + for (int count : preview.counts) counts.append(count); + QVariantMap result; + result.insert(QStringLiteral("counts"), counts); + result.insert(QStringLiteral("assignable"), preview.assignable); + Q_EMIT rulePreviewReady(revision, result); +} +void OrganizerViewPanel::setEditorError(QString message) { + if (message != m_editorError) { + m_editorError = std::move(message); + Q_EMIT editorChanged(); + } +} +void OrganizerViewPanel::reportError(const Error& error) { + if (auto* toasts = m_app.toasts()) + toasts->error(error.message); + else + spdlog::warn("organizer: {}", error.message.toStdString()); +} +void OrganizerViewPanel::treeCommand(const char* method) { + if (auto* root = m_qml->rootObject()) QMetaObject::invokeMethod(root, method); +} +void OrganizerViewPanel::openTags() { + setEditorError(); + treeCommand("openTags"); +} +void OrganizerViewPanel::openRules() { + setEditorError(); + auto* root = m_rulesQml->rootObject(); + if (!root) return; + auto* host = window(); + if (!m_rulesOpenedOnce) { + const auto available = host->screen()->availableGeometry(); + m_rulesDialog->resize(std::min({960, std::max(800, host->width() - 48), + available.width() - 32}), + std::min({760, std::max(600, host->height() - 80), + available.height() - 32})); + m_rulesOpenedOnce = true; + } + m_rulesDialog->move(host->frameGeometry().center() - + m_rulesDialog->rect().center()); + m_rulesDialog->open(); + m_rulesDialog->activateWindow(); + m_rulesQml->setFocus(Qt::OtherFocusReason); + QMetaObject::invokeMethod(root, "prepareForOpen"); +} +void OrganizerViewPanel::closeRules() { m_rulesDialog->close(); } +QCoro::QmlTask OrganizerViewPanel::createTag(QString name, QString color) { + return mutateTag(QStringLiteral("create"), 0, std::move(name), + std::move(color)); +} +QCoro::QmlTask OrganizerViewPanel::updateTag(qint64 id, QString name, + QString color) { + return mutateTag(QStringLiteral("update"), id, std::move(name), + std::move(color)); +} +QCoro::QmlTask OrganizerViewPanel::deleteTag(qint64 id) { + return mutateTag(QStringLiteral("delete"), id); +} +QCoro::QmlTask OrganizerViewPanel::assignTag(qint64 id, bool add) { + return mutateTag(QStringLiteral("assign"), id, {}, {}, add); +} +QCoro::Task OrganizerViewPanel::mutateTag(QString operation, qint64 id, + QString name, QString color, + bool add) { + const QPointer alive(this); + if (operation == QStringLiteral("create")) { + const auto result = + co_await m_service.createTag(std::move(name), std::move(color)); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; + } + Q_EMIT tagCreated(); + } else { + Result result; + if (operation == QStringLiteral("update")) + result = + co_await m_service.updateTag(id, std::move(name), std::move(color)); + else if (operation == QStringLiteral("delete")) + result = co_await m_service.deleteTag(id); + else + result = co_await m_service.setModTags( + selectedPackageIds(), add ? QSet{id} : QSet{}, + add ? QSet{} : QSet{id}); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; + } + if (operation == QStringLiteral("update")) Q_EMIT tagUpdated(id); + } + setEditorError(); +} +QCoro::QmlTask OrganizerViewPanel::saveRules(QVariantList rows) { + return persistRules(std::move(rows)); +} +QCoro::Task OrganizerViewPanel::persistRules(QVariantList rows) { + const QPointer alive(this); + auto specs = ruleSpecs(rows); + if (!specs) { + setEditorError(specs.error().message); + co_return; + } + const auto result = co_await m_service.setRules(std::move(*specs)); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; + } + setEditorError(); + Q_EMIT rulesSaved(); +} +QCoro::QmlTask OrganizerViewPanel::addStandardRules() { + return standardRules(); +} +QCoro::Task OrganizerViewPanel::standardRules() { + const QPointer alive(this); + const auto result = co_await m_service.addStandardRules(); + if (!alive) co_return; + setEditorError(result ? QString{} : result.error().message); +} +QVariantList OrganizerViewPanel::expandedIndexes() const { + QVariantList result; + for (const auto& index : m_model->expandedIndexes()) + result.append(QVariant::fromValue(index)); + return result; +} +void OrganizerViewPanel::selectRow(const QModelIndex& index, int modifiers, + const QVariantList& range) { + if ((modifiers & Qt::ShiftModifier) && m_selectionAnchor.isValid()) { + QList indexes; + for (const auto& value : range) indexes.append(value.value()); + m_model->select(indexes); + } else { + m_model->select({index}, modifiers & Qt::ControlModifier + ? QStringLiteral("toggle") + : QStringLiteral("replace")); + m_selectionAnchor = index; + } + syncInfo(); +} +void OrganizerViewPanel::selectSingle(const QModelIndex& index) { + if (!m_model->isSelected(index)) m_model->select({index}); + m_selectionAnchor = index; + syncInfo(); +} +QCoro::QmlTask OrganizerViewPanel::persistAllCollapsed(bool collapsed) { + return persistAllCollapse(collapsed); +} +QCoro::Task OrganizerViewPanel::persistAllCollapse(bool collapsed) { + const QPointer alive(this); + const auto result = co_await m_service.setAllCollapsed(collapsed); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::QmlTask OrganizerViewPanel::persistCollapsed(const QModelIndex& index, + bool collapsed) { + return persistCollapse(index, collapsed); +} +QCoro::Task OrganizerViewPanel::persistCollapse(QModelIndex index, + bool collapsed) { + const auto* node = m_model->nodeAt(index); + if (!node || !node->isContainer() || m_model->filtering()) co_return; + const QPointer alive(this); + const auto result = + co_await m_service.setCollapsed(node->folderId(), collapsed); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::QmlTask OrganizerViewPanel::toggleCheck(const QModelIndex& index) { + return toggle(index); +} +QCoro::Task OrganizerViewPanel::toggle(QModelIndex index) { + const auto* node = m_model->nodeAt(index); + if (!node) co_return; + if (node->leaf) { + const QStringList uuids{node->leaf->uuid}; + co_await toggleMods(m_ctx, this, uuids); + } else if (node->isContainer()) { + const auto change = m_service.folderToggle( + node->folderId(), node->kind != QStringLiteral("ungrouped")); + if (!change) { + reportError(change.error()); + co_return; + } + if (!change->enable.isEmpty() || !change->disable.isEmpty()) + co_await applyActivation(m_ctx, this, change->enable, change->disable); + } +} +QCoro::QmlTask OrganizerViewPanel::selectionAction(const QString& action) { + return runSelectionAction(action); +} +QCoro::Task OrganizerViewPanel::runSelectionAction(QString action) { + const auto uuids = selectedUuids(); + if (uuids.isEmpty()) co_return; + if (action == QStringLiteral("tags")) + openTags(); + else if (action == QStringLiteral("move") || + action == QStringLiteral("create")) { + const auto pids = selectedPackageIds(); + if (!pids.isEmpty()) co_await editFolder(action, kRootId, pids); + } else if (action == QStringLiteral("update")) + co_await updateMods(m_ctx, uuids, this); + else if (action == QStringLiteral("enable")) + co_await applyActivation(m_ctx, this, uuids); + else if (action == QStringLiteral("disable")) + co_await applyActivation(m_ctx, this, QStringList{}, uuids); +} +QCoro::QmlTask OrganizerViewPanel::newFolder() { + return editFolder(QStringLiteral("new")); +} +QCoro::Task OrganizerViewPanel::editFolder(QString action, qint64 id, + QStringList pids) { + const QPointer alive(this); + Result result; + if (action == QStringLiteral("new") || action == QStringLiteral("create") || + action == QStringLiteral("rename")) { + const bool rename = action == QStringLiteral("rename"); + const QString initial = + rename ? m_service.state().folders.value(id).name : QString{}; + QString title; + if (rename) + title = QStringLiteral("Rename Folder"); + else if (action == QStringLiteral("create")) + title = QStringLiteral("Create Folder from Selection"); + else + title = QStringLiteral("New Folder"); + const auto nameResult = co_await awaitDialog( + title, initial, this, + rename ? QStringLiteral("Rename") : QStringLiteral("Create")); + const int code = nameResult.first; + auto* const dialog = nameResult.second; + if (!alive) co_return; + if (code != QDialog::Accepted) co_return; + const auto name = dialog->textValue(); + if (rename) + result = co_await m_service.renameFolder(id, name); + else { + Result created; + if (action == QStringLiteral("create")) + created = co_await m_service.createFolderFrom(name, pids); + else + created = co_await m_service.createFolder(name, id); + if (!alive) co_return; + if (!created) reportError(created.error()); + co_return; + } + } else if (action == QStringLiteral("move") || + action == QStringLiteral("moveFolder")) { + QSet allowed; + if (action == QStringLiteral("moveFolder")) { + const auto targets = m_service.folderMoveTargets(id); + if (!targets) { + reportError(targets.error()); + co_return; + } + allowed = *targets; + } else { + for (auto it = m_service.state().folders.cbegin(); + it != m_service.state().folders.cend(); ++it) + allowed.insert(it.key()); + } + const auto pickerResult = co_await awaitDialog( + m_service.tree(), + action == QStringLiteral("move") ? QStringLiteral("Move to Folder") + : QStringLiteral("Move Folder"), + allowed, this); + const int code = pickerResult.first; + auto* const dialog = pickerResult.second; + if (!alive) co_return; + if (code != QDialog::Accepted) co_return; + const auto destination = dialog->folderId(); + if (action == QStringLiteral("moveFolder")) + result = co_await m_service.moveFolder(id, destination); + else { + co_await placeMods(pids, destination); + co_return; + } + } else if (action == QStringLiteral("delete")) { + const auto deleteResult = co_await awaitDialog( + m_service.state().folders.value(id).name, this); + const int code = deleteResult.first; + if (!alive) co_return; + if (code != QMessageBox::Yes) co_return; + result = co_await m_service.deleteFolder(id); + } + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::placeMods(QStringList pids, + qint64 destination) { + const QPointer alive(this); + Result result; + if (destination == kRootId) + result = co_await m_service.ungroup(pids); + else + result = co_await m_service.place(pids, destination); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::resetRules(QStringList pids) { + const QPointer alive(this); + const auto result = co_await m_service.resetToRules(pids); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +void OrganizerViewPanel::contextMenu(const QModelIndex& index) { + const auto* node = m_model->nodeAt(index); + m_menu->clear(); + if (!node) + m_menu->addAction(QStringLiteral("New folder…"), this, + &OrganizerViewPanel::newFolder); + else if (node->leaf) { + selectSingle(index); + modMenu(); + } else { + m_model->select({index}); + folderMenu(*node); + } + syncInfo(); + m_menu->popup(QCursor::pos()); +} +void OrganizerViewPanel::modMenu() { + const auto nodes = m_model->selectedNodes(); + const auto uuids = selectedUuids(); + bool inactive = false, active = false; + for (const auto& uuid : uuids) { + if (m_ctx.activeUuids().contains(uuid)) + active = true; + else + inactive = true; + } + if (inactive) + m_menu->addAction(QStringLiteral("Enable"), this, + [this, uuids] { applyActivation(m_ctx, this, uuids); }); + if (active) + m_menu->addAction(QStringLiteral("Disable"), this, [this, uuids] { + applyActivation(m_ctx, this, {}, uuids); + }); + addUpdateAction(m_menu, m_ctx, uuids, + [this, uuids] { updateMods(m_ctx, uuids, this); }); + m_menu->addSeparator(); + const auto pids = selectedPackageIds(); + bool placeable = !pids.isEmpty(), grouped = false; + QStringList manual; + for (const auto* node : nodes) + if (node->leaf) { + placeable &= node->leaf->packageId.has_value(); + grouped |= node->folderId() != kRootId; + if (node->leaf->packageId && + node->leaf->placement == Placement::kManual && + !manual.contains(*node->leaf->packageId)) + manual.append(*node->leaf->packageId); + } + auto* move = m_menu->addAction( + QStringLiteral("Move to folder…"), this, + [this, pids] { editFolder(QStringLiteral("move"), kRootId, pids); }); + auto* remove = m_menu->addAction(QStringLiteral("Remove from folder"), this, + [this, pids] { placeMods(pids, kRootId); }); + auto* create = m_menu->addAction( + QStringLiteral("Create folder from selection…"), this, + [this, pids] { editFolder(QStringLiteral("create"), kRootId, pids); }); + for (auto* action : {move, remove, create}) { + action->setEnabled(placeable); + action->setToolTip( + placeable + ? QString{} + : QStringLiteral( + "Mods without a package ID cannot be placed in folders")); + } + remove->setEnabled(placeable && grouped); + m_menu + ->addAction(QStringLiteral("Manage tags…"), this, + &OrganizerViewPanel::openTags) + ->setEnabled(placeable); + if (placeable && !manual.isEmpty()) + m_menu->addAction(QStringLiteral("Use auto rules"), this, + [this, manual] { resetRules(manual); }); +} +void OrganizerViewPanel::folderMenu(const TreeNode& node) { + const auto id = node.folderId(); + const auto result = + m_service.folderModUuids(id, node.kind != QStringLiteral("ungrouped")); + if (!result) { + reportError(result.error()); + return; + } + const auto uuids = *result; + m_menu + ->addAction(QStringLiteral("Enable all"), this, + [this, uuids] { applyActivation(m_ctx, this, uuids); }) + ->setEnabled(!uuids.isEmpty()); + m_menu + ->addAction(QStringLiteral("Disable all"), this, + [this, uuids] { applyActivation(m_ctx, this, {}, uuids); }) + ->setEnabled(!uuids.isEmpty()); + addUpdateAction(m_menu, m_ctx, uuids, + [this, uuids] { updateMods(m_ctx, uuids, this); }); + m_menu->addSeparator(); + if (node.kind == QStringLiteral("ungrouped")) { + m_menu->addAction(QStringLiteral("New folder…"), this, + &OrganizerViewPanel::newFolder); + return; + } + if (node.folder && node.folder->depth < kMaxDepth) + m_menu->addAction(QStringLiteral("New subfolder…"), this, + [this, id] { editFolder(QStringLiteral("new"), id); }); + m_menu->addAction(QStringLiteral("Rename…"), this, + [this, id] { editFolder(QStringLiteral("rename"), id); }); + const auto targets = m_service.folderMoveTargets(id); + m_menu + ->addAction(QStringLiteral("Move to…"), this, + [this, id] { editFolder(QStringLiteral("moveFolder"), id); }) + ->setEnabled(targets && !targets->isEmpty()); + m_menu->addAction(QStringLiteral("Delete…"), this, + [this, id] { editFolder(QStringLiteral("delete"), id); }); +} +bool OrganizerViewPanel::canDrop(const QModelIndex& source, + const QModelIndex& target) const { + const auto* src = m_model->nodeAt(source); + const auto* dst = m_model->nodeAt(target); + if (!src || !dst || !dst->isContainer() || src == dst) return false; + if (src->leaf) { + if (!m_model->isSelected(source)) return src->leaf->packageId.has_value(); + bool any = false; + for (const auto* node : m_model->selectedNodes()) { + if (!node->leaf) continue; + any = true; + if (!node->leaf->packageId) return false; + } + return any; + } + if (src->kind == QStringLiteral("folder")) { + const auto targets = m_service.folderMoveTargets(src->folderId()); + return targets && targets->contains(dst->folderId()); + } + return false; +} +QCoro::QmlTask OrganizerViewPanel::dropOn(const QModelIndex& source, + const QModelIndex& target) { + return performDrop(source, target); +} +QCoro::Task OrganizerViewPanel::performDrop(QModelIndex source, + QModelIndex target) { + const QPointer alive(this); + if (!canDrop(source, target)) co_return; + const auto* src = m_model->nodeAt(source); + const auto* dst = m_model->nodeAt(target); + const auto destination = dst->folderId(); + if (src->kind == QStringLiteral("folder")) { + const auto result = + co_await m_service.moveFolder(src->folderId(), destination); + if (!alive) co_return; + if (!result) reportError(result.error()); + } else { + const auto pids = m_model->isSelected(source) + ? selectedPackageIds() + : QStringList{*src->leaf->packageId}; + co_await placeMods(pids, destination); + } +} +} // namespace px::modrim diff --git a/src/ui/plugins/organizer/view.hpp b/src/ui/plugins/organizer/view.hpp new file mode 100644 index 0000000..1f21ce9 --- /dev/null +++ b/src/ui/plugins/organizer/view.hpp @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "core/organizer/service.hpp" +#include "ui/views/base.hpp" + +class QDialog; +class QLabel; +class QLineEdit; +class QMenu; +class QPushButton; +class QQuickWidget; +class QTimer; +class QShowEvent; + +namespace px::modrim { +class ModTreeModel; +class OrganizerFilterModel; +class ModInfoPanel; +class ModSelectionPresenter; +struct TreeNode; + +class OrganizerViewPanel final : public BaseViewPanel { + Q_OBJECT + Q_PROPERTY(bool ready READ ready NOTIFY readyChanged) + Q_PROPERTY(int selectedTagCount READ selectedTagCount NOTIFY editorChanged) + Q_PROPERTY(QVariantList tagRows READ tagRows NOTIFY editorChanged) + Q_PROPERTY(QVariantList ruleRows READ ruleRows NOTIFY editorChanged) + Q_PROPERTY(QVariantList ruleFolders READ ruleFolders NOTIFY editorChanged) + Q_PROPERTY(QString editorError READ editorError NOTIFY editorChanged) + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("organizer"), + QLatin1StringView("folder"), + QLatin1StringView("Organizer")}; + OrganizerViewPanel(CoreContext&, QQmlEngine*, QWidget*, AppContext&); + ~OrganizerViewPanel() override; + bool ready() const; + int selectedTagCount() const; + QVariantList tagRows() const; + QVariantList ruleRows() const; + QVariantList ruleFolders() const; + QString editorError() const { return m_editorError; } + Q_INVOKABLE QCoro::QmlTask requestRulePreview(const QVariantList&, + int revision); + Q_INVOKABLE void openTags(); + Q_INVOKABLE void openRules(); + Q_INVOKABLE void closeRules(); + Q_INVOKABLE QCoro::QmlTask createTag(QString name, QString color); + Q_INVOKABLE QCoro::QmlTask updateTag(qint64 id, QString name, QString color); + Q_INVOKABLE QCoro::QmlTask deleteTag(qint64 id); + Q_INVOKABLE QCoro::QmlTask assignTag(qint64 id, bool add); + Q_INVOKABLE QCoro::QmlTask saveRules(QVariantList rows); + Q_INVOKABLE QCoro::QmlTask addStandardRules(); + Q_INVOKABLE QVariantList expandedIndexes() const; + Q_INVOKABLE void selectRow(const QModelIndex&, int modifiers, + const QVariantList& range); + Q_INVOKABLE void selectSingle(const QModelIndex&); + Q_INVOKABLE QCoro::QmlTask persistAllCollapsed(bool); + Q_INVOKABLE QCoro::QmlTask persistCollapsed(const QModelIndex&, bool); + Q_INVOKABLE QCoro::QmlTask toggleCheck(const QModelIndex&); + Q_INVOKABLE void contextMenu(const QModelIndex&); + Q_INVOKABLE QCoro::QmlTask selectionAction(const QString&); + Q_INVOKABLE QCoro::QmlTask newFolder(); + Q_INVOKABLE bool canDrop(const QModelIndex&, const QModelIndex&) const; + Q_INVOKABLE QCoro::QmlTask dropOn(const QModelIndex&, const QModelIndex&); + void rebuild(); + Q_SIGNALS: + void readyChanged(); + void editorChanged(); + void tagCreated(); + void tagUpdated(qint64 id); + void rulesSaved(); + void rulePreviewReady(int revision, const QVariantMap& result); + + protected: + void showEvent(QShowEvent*) override; + + private: + void invalidate(); + void syncInfo(bool force = false); + void treeCommand(const char*); + void setEditorError(QString message = {}); + void reportError(const Error&); + QStringList selectedPackageIds() const; + QStringList selectedUuids() const; + Result> ruleSpecs(const QVariantList&) const; + QCoro::Task calculateRulePreview(QVariantList, int); + QCoro::Task mutateTag(QString operation, qint64 id, QString name = {}, + QString color = {}, bool add = false); + QCoro::Task persistRules(QVariantList); + QCoro::Task standardRules(); + QCoro::Task persistCollapse(QModelIndex, bool); + QCoro::Task persistAllCollapse(bool); + QCoro::Task toggle(QModelIndex); + QCoro::Task runSelectionAction(QString); + QCoro::Task editFolder(QString action, qint64 id = organizer::kRootId, + QStringList pids = {}); + QCoro::Task placeMods(QStringList, qint64); + QCoro::Task resetRules(QStringList); + QCoro::Task performDrop(QModelIndex, QModelIndex); + void modMenu(); + void folderMenu(const TreeNode&); + + organizer::OrganizerService& m_service; + ModTreeModel* m_model; + OrganizerFilterModel* m_filters; + ModInfoPanel* m_info; + std::unique_ptr m_selection; + QQuickWidget* m_qml; + QQuickWidget* m_rulesQml; + QDialog* m_rulesDialog; + QLineEdit* m_search; + QLabel* m_status; + QLabel* m_selectionLabel; + QWidget* m_selectionBar; + QPushButton* m_updateButton; + QMenu* m_menu; + QTimer* m_searchTimer; + QPersistentModelIndex m_selectionAnchor; + QString m_selectedUuid; + QString m_filterKey = QStringLiteral("all"); + QString m_searchText; + QString m_editorError; + bool m_dirty = true; + bool m_rulesOpenedOnce = false; + std::uint64_t m_changedId = 0; + std::uint64_t m_activeId = 0; + std::uint64_t m_modsId = 0; + std::uint64_t m_diagnosticsId = 0; + std::uint64_t m_busyId = 0; +}; +} // namespace px::modrim diff --git a/src/ui/plugins/steam_downloader/plugin.cpp b/src/ui/plugins/steam_downloader/plugin.cpp new file mode 100644 index 0000000..503fdaf --- /dev/null +++ b/src/ui/plugins/steam_downloader/plugin.cpp @@ -0,0 +1,29 @@ +#include "ui/plugins/steam_downloader/plugin.hpp" + +#ifdef PX_HAS_STEAM +namespace px::modrim { + +SteamSettingsSection::SteamSettingsSection( + PluginConfig& settings, QObject* parent) + : PluginConfigSection(settings, parent) {} +QString SteamSettingsSection::title() const { + return QStringLiteral("Steam Workshop"); +} +QUrl SteamSettingsSection::source() const { + return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamSettings.qml")); +} + +SteamDownloaderUiPlugin::SteamDownloaderUiPlugin() + : UiPlugin(QStringLiteral("steam_downloader_ui"), + {QStringLiteral("steam_downloader")}) {} + +void SteamDownloaderUiPlugin::setup(AppContext& ctx) { + auto& downloader = *static_cast( + ctx.coreContext().plugins().get(QStringLiteral("steam_downloader"))); + ctx.addSettingsSection([&downloader](QObject* parent) { + return new SteamSettingsSection(downloader.settings(), parent); + }); +} + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/steam_downloader/plugin.hpp b/src/ui/plugins/steam_downloader/plugin.hpp new file mode 100644 index 0000000..019d100 --- /dev/null +++ b/src/ui/plugins/steam_downloader/plugin.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "ui/context.hpp" + +#ifdef PX_HAS_STEAM +#include "core/downloads/steam.hpp" +#include "ui/settings_section.hpp" + +namespace px::modrim { + +class SteamSettingsSection final : public PluginConfigSection { + public: + SteamSettingsSection(PluginConfig& settings, QObject* parent); + QString title() const override; + QUrl source() const override; +}; + +class SteamDownloaderUiPlugin final : public UiPlugin { + public: + SteamDownloaderUiPlugin(); + void setup(AppContext& ctx) override; +}; + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/steam_workshop/bridge_obj.cpp b/src/ui/plugins/steam_workshop/bridge_obj.cpp new file mode 100644 index 0000000..c516205 --- /dev/null +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -0,0 +1,132 @@ +#include "ui/plugins/steam_workshop/bridge_obj.hpp" + +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include + +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/steam_workshop/view.hpp" + +namespace px::modrim { +namespace { +bool isPublishedFileId(const QJsonValue& value) { + if (!value.isString()) return false; + const auto text = value.toString(); + return !text.isEmpty() && std::ranges::all_of(text, [](QChar c) { + return c >= QLatin1Char('0') && c <= QLatin1Char('9'); + }); +} +} // namespace + +PxModRimBridge::PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent) + : QObject(parent), m_plugin(plugin) {} + +void PxModRimBridge::setPage(QObject* page) { m_page = page; } + +void PxModRimBridge::call(const QString& requestId, const QString& method, + const QString& payload) { + handle(requestId, method, payload); +} + +void PxModRimBridge::respond(const QString& requestId, bool ok, + const QJsonValue& result, const QString& error) { + QJsonObject response{{QStringLiteral("ok"), ok}}; + if (ok) + response.insert(QStringLiteral("result"), result); + else + response.insert(QStringLiteral("error"), error); + emit resultReady(requestId, QString::fromUtf8(QJsonDocument(response).toJson( + QJsonDocument::Compact))); +} + +QCoro::Task PxModRimBridge::handle(QString requestId, QString method, + QString payload) { + if (!m_page || + !SteamWorkshopViewPanel::isSteamOrigin(m_page->property("url").toUrl())) { + respond(requestId, false, {}, + QStringLiteral("The bridge is only available on Steam pages")); + co_return; + } + QJsonParseError parseError; + const auto document = QJsonDocument::fromJson( + payload.isEmpty() ? QByteArray("{}") : payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + respond(requestId, false, {}, QStringLiteral("Invalid request payload")); + co_return; + } + const auto params = document.object(); + const auto modId = params.value(QStringLiteral("mod_id")); + if (method == QLatin1String("init_ready")) { + m_plugin.syncAll(); + } else if (method == QLatin1String("toggle_download_checked")) { + if (isPublishedFileId(modId)) + m_plugin.toggleDownloadChecked( + modId.toString(), params.value(QStringLiteral("title")).toString(), + params.value(QStringLiteral("checked")).toBool()); + } else if (method == QLatin1String("batch_toggle_download_checked")) { + const auto ids = params.value(QStringLiteral("mod_ids")); + const auto titles = params.value(QStringLiteral("titles")); + if (!ids.isArray() || !titles.isArray() || + ids.toArray().size() != titles.toArray().size()) { + respond(requestId, false, {}, + QStringLiteral("mod_ids and titles must have equal lengths")); + co_return; + } + const auto idArray = ids.toArray(); + const auto titleArray = titles.toArray(); + QStringList validIds; + QStringList validTitles; + for (qsizetype pos = 0; pos < idArray.size(); ++pos) { + if (!isPublishedFileId(idArray[pos])) continue; + validIds.append(idArray[pos].toString()); + validTitles.append(titleArray[pos].toString()); + } + if (!validIds.isEmpty()) + m_plugin.batchToggleDownloadChecked( + validIds, validTitles, + params.value(QStringLiteral("checked")).toBool()); + } else if (method == QLatin1String("toggle_active")) { + if (isPublishedFileId(modId)) + m_plugin.toggleActive(modId.toString(), + params.value(QStringLiteral("active")).toBool()); + } else if (method == QLatin1String("batch_toggle_active")) { + const auto ids = params.value(QStringLiteral("mod_ids")); + if (!ids.isArray()) { + respond(requestId, false, {}, QStringLiteral("mod_ids must be an array")); + co_return; + } + QStringList validIds; + for (const auto& id : ids.toArray()) { + if (isPublishedFileId(id)) validIds.append(id.toString()); + } + if (!validIds.isEmpty()) + m_plugin.batchToggleActive( + validIds, params.value(QStringLiteral("active")).toBool()); + } else if (method == QLatin1String("fetch_mod_deps")) { + if (isPublishedFileId(modId)) { + QPointer guard(this); + auto result = co_await m_plugin.fetchModDeps(modId.toString()); + if (guard) { + if (result) + guard->respond(requestId, true, *result); + else + guard->respond(requestId, false, {}, result.error().message); + } + co_return; + } + } else { + respond(requestId, false, {}, + QStringLiteral("Unknown method: %1").arg(method)); + co_return; + } + respond(requestId, true); +} + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/steam_workshop/bridge_obj.hpp b/src/ui/plugins/steam_workshop/bridge_obj.hpp new file mode 100644 index 0000000..5949e8a --- /dev/null +++ b/src/ui/plugins/steam_workshop/bridge_obj.hpp @@ -0,0 +1,35 @@ +#pragma once + +#ifdef PX_HAS_STEAM + +#include +#include +#include + +#include + +namespace px::modrim { + +class SteamWorkshopUiPlugin; + +class PxModRimBridge final : public QObject { + Q_OBJECT + public: + PxModRimBridge(SteamWorkshopUiPlugin& plugin, QObject* parent); + void setPage(QObject* page); + Q_INVOKABLE void call(const QString& requestId, const QString& method, + const QString& payload); + signals: + void resultReady(const QString& requestId, const QString& payload); + + private: + QCoro::Task handle(QString requestId, QString method, QString payload); + void respond(const QString& requestId, bool ok, + const QJsonValue& result = QJsonValue(QJsonValue::Null), + const QString& error = {}); + SteamWorkshopUiPlugin& m_plugin; + QPointer m_page; +}; + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/steam_workshop/download_queue_model.cpp b/src/ui/plugins/steam_workshop/download_queue_model.cpp new file mode 100644 index 0000000..5c4f90e --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -0,0 +1,67 @@ +#include "ui/plugins/steam_workshop/download_queue_model.hpp" + +namespace px::modrim { + +DownloadQueueModel::DownloadQueueModel(QObject* parent) + : QAbstractListModel(parent) {} + +int DownloadQueueModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : static_cast(m_items.size()); +} + +QVariant DownloadQueueModel::data(const QModelIndex& index, int role) const { + if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) + return {}; + const auto& item = m_items[index.row()]; + switch (role) { + case kIdRole: + return item.id; + case kTitleRole: + return item.title; + case kDisplayRole: + return item.display; + case kStatusRole: + return item.status; + case kProgressRole: + return item.progress; + default: + return {}; + } +} + +QHash DownloadQueueModel::roleNames() const { + return {{kIdRole, "id"}, + {kTitleRole, "title"}, + {kDisplayRole, "display"}, + {kStatusRole, "status"}, + {kProgressRole, "progress"}}; +} + +void DownloadQueueModel::syncFrom(const CheckedDownloads& checked, + const QHash& statuses) { + beginResetModel(); + m_items.clear(); + m_items.reserve(checked.size()); + for (const auto& [id, title] : checked) { + m_items.append( + Item{id, title.isEmpty() ? id : title, + title.isEmpty() ? id : QStringLiteral("%1 (%2)").arg(title, id), + statuses.value(id)}); + } + endResetModel(); +} + +void DownloadQueueModel::updateStatus(const QString& id, const QString& status, + qint64 done, qint64 total) { + for (qsizetype pos = 0; pos < m_items.size(); ++pos) { + auto& item = m_items[pos]; + if (item.id != id) continue; + item.status = status; + item.progress = total ? static_cast(done) / total : 0; + const auto idx = index(static_cast(pos)); + emit dataChanged(idx, idx, {kStatusRole, kProgressRole}); + return; + } +} + +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_queue_model.hpp b/src/ui/plugins/steam_workshop/download_queue_model.hpp new file mode 100644 index 0000000..b40a23d --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_queue_model.hpp @@ -0,0 +1,44 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace px::modrim { + +using CheckedDownloads = QList>; + +class DownloadQueueModel final : public QAbstractListModel { + Q_OBJECT + public: + enum Role { + kIdRole = Qt::UserRole + 1, + kTitleRole, + kDisplayRole, + kStatusRole, + kProgressRole + }; + explicit DownloadQueueModel(QObject* parent = nullptr); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + void syncFrom(const CheckedDownloads& checked, + const QHash& statuses = {}); + void updateStatus(const QString& id, const QString& status, qint64 done = 0, + qint64 total = 0); + + private: + struct Item { + QString id; + QString title; + QString display; + QString status; + double progress = 0; + }; + QList m_items; +}; + +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.cpp b/src/ui/plugins/steam_workshop/download_sidebar.cpp new file mode 100644 index 0000000..81b8083 --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_sidebar.cpp @@ -0,0 +1,50 @@ +#include "ui/plugins/steam_workshop/download_sidebar.hpp" + +#include +#include +#include +#include + +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +DownloadSidebar::DownloadSidebar(QQmlEngine* engine, QWidget* parent) + : QWidget(parent), + m_model(new DownloadQueueModel(this)), + m_qml(new QQuickWidget(engine, this)) { + setObjectName(QStringLiteral("downloadSidebar")); + setFixedWidth(kSidebarWidth); + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_2"))); + m_qml->rootContext()->setContextProperty(QStringLiteral("downloadSidebar"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("downloadQueueModel"), + m_model); + m_qml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/DownloadSidebar.qml"))); + layout->addWidget(m_qml); +} + +void DownloadSidebar::downloadRequested() { emit downloadTriggered(); } +void DownloadSidebar::stopRequested() { emit stopTriggered(); } +void DownloadSidebar::removeItem(const QString& id) { emit itemRemoved(id); } +void DownloadSidebar::clearQueue() { emit clearRequested(); } +void DownloadSidebar::setDownloadEnabled(bool enabled) { + if (auto* root = m_qml->rootObject()) + root->setProperty("downloadEnabled", enabled); +} +void DownloadSidebar::syncFrom(const CheckedDownloads& checked, + const QHash& statuses) { + m_model->syncFrom(checked, statuses); +} +void DownloadSidebar::updateStatus(const QString& id, const QString& status, + qint64 done, qint64 total) { + m_model->updateStatus(id, status, done, total); +} + +} // namespace px::modrim diff --git a/src/ui/plugins/steam_workshop/download_sidebar.hpp b/src/ui/plugins/steam_workshop/download_sidebar.hpp new file mode 100644 index 0000000..6092810 --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_sidebar.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "ui/plugins/steam_workshop/download_queue_model.hpp" + +class QQmlEngine; +class QQuickWidget; + +namespace px::modrim { + +class DownloadSidebar final : public QWidget { + Q_OBJECT + public: + DownloadSidebar(QQmlEngine* engine, QWidget* parent); + Q_INVOKABLE void downloadRequested(); + Q_INVOKABLE void stopRequested(); + Q_INVOKABLE void removeItem(const QString& id); + Q_INVOKABLE void clearQueue(); + void setDownloadEnabled(bool enabled); + void syncFrom(const CheckedDownloads& checked, + const QHash& statuses = {}); + void updateStatus(const QString& id, const QString& status, qint64 done = 0, + qint64 total = 0); + signals: + void downloadTriggered(); + void stopTriggered(); + void itemRemoved(const QString& id); + void clearRequested(); + + private: + DownloadQueueModel* m_model; + QQuickWidget* m_qml; +}; + +} // namespace px::modrim diff --git a/src/pxmodrim/ui/plugins/steam_workshop/inject.css b/src/ui/plugins/steam_workshop/inject.css similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/inject.css rename to src/ui/plugins/steam_workshop/inject.css diff --git a/src/ui/plugins/steam_workshop/plugin.cpp b/src/ui/plugins/steam_workshop/plugin.cpp new file mode 100644 index 0000000..5cc30ba --- /dev/null +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -0,0 +1,264 @@ +#include "ui/plugins/steam_workshop/plugin.hpp" + +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/mod_service.hpp" +#include "ui/navigation.hpp" +#include "ui/plugins/steam_workshop/view.hpp" + +namespace px::modrim { + +SteamWorkshopUiPlugin::SteamWorkshopUiPlugin() + : UiPlugin(QStringLiteral("steamworkshop"), + {QStringLiteral("steam_downloader")}) {} +SteamWorkshopUiPlugin::~SteamWorkshopUiPlugin() { disconnectEvents(); } + +void SteamWorkshopUiPlugin::setup(AppContext& ctx) { + m_core = &ctx.coreContext(); + m_app = &ctx; + m_service = static_cast( + m_core->plugins().get(QStringLiteral("steam_downloader"))); + ctx.addRailView(); + m_modsId = + m_core->modService().modsChanged.connect([this](const std::monostate&) { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); + }); + m_activeId = m_core->activeStateChanged().connect([this](const QStringList&) { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); + }); + m_statusId = m_service->downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& status) { onItemStatus(status); }); + m_finishedId = m_service->downloadFinished.connect( + [this](const DownloadResult& result) { onDownloadFinished(result); }); + m_busyId = m_service->busyChanged.connect( + [this](bool busy) { downloadBusyChanged.notify(busy); }); +} + +QCoro::Task SteamWorkshopUiPlugin::init(AppContext&) { + refreshCachedIds(); + co_return; +} + +QCoro::Task SteamWorkshopUiPlugin::shutdown() { + disconnectEvents(); + co_return; +} + +void SteamWorkshopUiPlugin::disconnectEvents() { + if (!m_core) return; + m_core->modService().modsChanged.disconnect(m_modsId); + m_core->activeStateChanged().disconnect(m_activeId); + m_service->downloadItemStatusChanged.disconnect(m_statusId); + m_service->downloadFinished.disconnect(m_finishedId); + m_service->busyChanged.disconnect(m_busyId); + m_core = nullptr; + m_app = nullptr; +} + +void SteamWorkshopUiPlugin::toggleDownloadChecked(const QString& id, + const QString& title, + bool checked) { + const auto it = + std::ranges::find(m_checkedIds, id, &CheckedDownloads::value_type::first); + if (checked) { + if (it == m_checkedIds.end()) + m_checkedIds.append({id, title}); + else + it->second = title; + } else if (it != m_checkedIds.end()) { + m_checkedIds.erase(it); + } + notifySidebar(); +} + +void SteamWorkshopUiPlugin::batchToggleDownloadChecked( + const QStringList& ids, const QStringList& titles, bool checked) { + for (qsizetype pos = 0; pos < ids.size(); ++pos) { + const auto it = std::ranges::find(m_checkedIds, ids[pos], + &CheckedDownloads::value_type::first); + if (checked) { + if (it == m_checkedIds.end()) + m_checkedIds.append({ids[pos], titles[pos]}); + else + it->second = titles[pos]; + } else if (it != m_checkedIds.end()) { + m_checkedIds.erase(it); + } + } + if (!ids.isEmpty()) notifySidebar(); +} + +void SteamWorkshopUiPlugin::toggleActive(const QString& id, bool active) { + batchToggleActive({id}, active); +} + +void SteamWorkshopUiPlugin::batchToggleActive(const QStringList& ids, + bool active) { + if (!m_core) return; + const QSet requested(ids.cbegin(), ids.cend()); + auto current = m_core->activeUuids(); + QHash chosenCopies; + for (auto it = m_core->allMods().cbegin(); it != m_core->allMods().cend(); + ++it) { + const auto id = it.value()->publishedFileId(); + if (!id || !requested.contains(*id)) continue; + if (!active) { + current.removeAll(it.key()); + } else if (!chosenCopies.contains(*id) || current.contains(it.key())) { + chosenCopies.insert(*id, it.key()); + } + } + if (active) { + for (const auto& id : ids) { + const auto copy = chosenCopies.constFind(id); + if (copy != chosenCopies.cend() && !current.contains(*copy)) + current.append(*copy); + } + } + m_core->setActive(std::move(current)); +} + +QCoro::Task SteamWorkshopUiPlugin::requestDownload() { + if (m_checkedIds.isEmpty()) co_return; + if (!m_core || m_core->config().paths.local.empty()) { + m_service->statusMessageChanged.notify( + QStringLiteral("Local mods path is not configured")); + co_return; + } + if (m_service->isDownloading()) { + m_service->statusMessageChanged.notify( + QStringLiteral("A download is already running")); + co_return; + } + QStringList ids; + ids.reserve(m_checkedIds.size()); + for (const auto& [id, title] : m_checkedIds) { + ids.append(id); + m_statuses.insert(id, QStringLiteral("queued")); + } + m_ownDownload = true; + if (m_app) m_app->navigate(buildRoute(QStringLiteral("downloads"))); + notifySidebar(); + auto result = co_await m_service->downloadMods(std::move(ids)); + m_ownDownload = false; + if (!result) m_service->statusMessageChanged.notify(result.error().message); +} + +void SteamWorkshopUiPlugin::stopDownload() { m_service->cancel(); } +bool SteamWorkshopUiPlugin::isDownloading() const { + return m_service->isDownloading(); +} + +void SteamWorkshopUiPlugin::removeItem(const QString& id) { + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); + notifySidebar(); + uncheckModRequested.notify(id); +} + +void SteamWorkshopUiPlugin::clearQueue() { + m_checkedIds.clear(); + m_statuses.clear(); + notifySidebar(); + clearCheckedRequested.notify(std::monostate{}); +} + +void SteamWorkshopUiPlugin::notifySidebar() { + sidebarSyncRequested.notify(SidebarSync{m_checkedIds, m_statuses}); +} + +void SteamWorkshopUiPlugin::syncAll() { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); + notifySidebar(); +} + +void SteamWorkshopUiPlugin::onItemStatus(const DownloadItemStatus& status) { + if (!m_ownDownload) return; + m_statuses.insert(status.modId, status.status); + itemStatusChanged.notify(status); +} + +void SteamWorkshopUiPlugin::onDownloadFinished(const DownloadResult& result) { + if (!m_ownDownload) return; + for (const auto& id : result.succeeded) { + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); + } + for (auto it = m_statuses.begin(); it != m_statuses.end();) { + if (it.value() == QLatin1String("queued") || + it.value() == QLatin1String("downloading")) + it = m_statuses.erase(it); + else + ++it; + } + notifySidebar(); +} + +void SteamWorkshopUiPlugin::refreshCachedIds() { + if (!m_core) return; + m_installedIds.clear(); + m_activeIds.clear(); + const QSet active(m_core->activeUuids().cbegin(), + m_core->activeUuids().cend()); + for (const auto& mod : m_core->allMods()) { + if (auto id = mod->publishedFileId()) { + m_installedIds.insert(*id); + if (active.contains(mod->uuid())) m_activeIds.insert(*id); + } + } +} + +QCoro::Task> SteamWorkshopUiPlugin::fetchModDeps( + QString id) { + QUrl url(QStringLiteral("https://deps.modrim.pyxiion.ru/deps")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("id"), id); + url.setQuery(query); + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "PxModRim/1.0"); + request.setTransferTimeout(30000); + auto* reply = m_core->network().get(request); + if (!co_await qCoro(reply).waitForFinished(std::chrono::seconds{30})) { + reply->abort(); + reply->deleteLater(); + co_return std::unexpected(Error{QStringLiteral("Timeout")}); + } + const auto error = reply->error(); + const auto status = + reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const auto bytes = reply->readAll(); + const auto message = reply->errorString(); + reply->deleteLater(); + if (error == QNetworkReply::TimeoutError) + co_return std::unexpected(Error{QStringLiteral("Timeout")}); + if (status && status != 200) co_return QJsonValue(QJsonValue::Null); + if (error != QNetworkReply::NoError) + co_return std::unexpected(Error{message}); + if (bytes.isEmpty()) co_return QJsonValue(QJsonValue::Null); + QJsonParseError parseError; + const auto document = + QJsonDocument::fromJson(QByteArray("[") + bytes + ']', &parseError); + if (parseError.error != QJsonParseError::NoError) + co_return std::unexpected(Error{parseError.errorString()}); + co_return document.array().at(0); +} + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/steam_workshop/plugin.hpp b/src/ui/plugins/steam_workshop/plugin.hpp new file mode 100644 index 0000000..d28021b --- /dev/null +++ b/src/ui/plugins/steam_workshop/plugin.hpp @@ -0,0 +1,72 @@ +#pragma once + +#ifdef PX_HAS_STEAM + +#include +#include +#include + +#include "core/downloads/steam.hpp" +#include "ui/context.hpp" +#include "ui/plugins/steam_workshop/download_queue_model.hpp" + +namespace px::modrim { + +struct SidebarSync { + CheckedDownloads checkedIds; + QHash statuses; +}; + +class SteamWorkshopUiPlugin final : public UiPlugin { + public: + SteamWorkshopUiPlugin(); + ~SteamWorkshopUiPlugin() override; + void setup(AppContext& ctx) override; + QCoro::Task init(AppContext& ctx) override; + QCoro::Task shutdown() override; + + void toggleDownloadChecked(const QString& id, const QString& title, + bool checked); + void batchToggleDownloadChecked(const QStringList& ids, + const QStringList& titles, bool checked); + void toggleActive(const QString& id, bool active); + void batchToggleActive(const QStringList& ids, bool active); + QCoro::Task requestDownload(); + void stopDownload(); + void removeItem(const QString& id); + void clearQueue(); + void syncAll(); + bool isDownloading() const; + QCoro::Task> fetchModDeps(QString id); + + Event badgesRefreshRequested; + Event activeRefreshRequested; + Event sidebarSyncRequested; + Event itemStatusChanged; + Event downloadBusyChanged; + Event uncheckModRequested; + Event clearCheckedRequested; + + private: + void disconnectEvents(); + void refreshCachedIds(); + void notifySidebar(); + void onItemStatus(const DownloadItemStatus& status); + void onDownloadFinished(const DownloadResult& result); + CoreContext* m_core = nullptr; + AppContext* m_app = nullptr; + SteamDownloader* m_service = nullptr; + QSet m_installedIds; + QSet m_activeIds; + CheckedDownloads m_checkedIds; + QHash m_statuses; + bool m_ownDownload = false; + Event::Id m_modsId = 0; + Event::Id m_activeId = 0; + Event::Id m_statusId = 0; + Event::Id m_finishedId = 0; + Event::Id m_busyId = 0; +}; + +} // namespace px::modrim +#endif diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/api.ts b/src/ui/plugins/steam_workshop/ts/api.ts similarity index 97% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/api.ts rename to src/ui/plugins/steam_workshop/ts/api.ts index 7b04244..b314430 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/api.ts +++ b/src/ui/plugins/steam_workshop/ts/api.ts @@ -1,5 +1,5 @@ import type { DepItemsResult } from "./types"; -import { PxModRimRPC } from "./bridge"; +import type { PxModRimRPC } from "./bridge"; let _rpc: PxModRimRPC | null = null; diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts b/src/ui/plugins/steam_workshop/ts/badges.ts similarity index 98% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts rename to src/ui/plugins/steam_workshop/ts/badges.ts index 50919c1..287140f 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/badges.ts +++ b/src/ui/plugins/steam_workshop/ts/badges.ts @@ -1,4 +1,3 @@ -import { ModState } from "./types"; import { log, getModId, getModTitle, findItemCard } from "./utils"; import { getModState, applyModState, handleClick } from "./controls"; import { isBridgeDataReady } from "./bridge"; diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts b/src/ui/plugins/steam_workshop/ts/bridge.ts similarity index 90% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts rename to src/ui/plugins/steam_workshop/ts/bridge.ts index dce12ee..9db2f43 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/bridge.ts +++ b/src/ui/plugins/steam_workshop/ts/bridge.ts @@ -1,7 +1,6 @@ import "./qwebchannel.js"; import { getModState, applyModState } from "./controls"; import { refreshAllDepsBadges, updateAllModBadges } from "./badges"; -import { log } from "./utils"; // Ensure __pxmodrim exists (modules may initialize before main.ts guard) if (!window.__pxmodrim) { @@ -15,7 +14,6 @@ if (!window.__pxmodrim) { let _bridgeDataReady = false; let _bridgeDataResolve: (() => void) | null = null; -let _bridgeDataReject: ((reason: Error) => void) | null = null; let _bridgeDataPromise: Promise | null = null; export function isBridgeDataReady(): boolean { @@ -31,11 +29,9 @@ export function waitForBridgeData(timeoutMs = 15000): Promise { if (!_bridgeDataPromise) { _bridgeDataPromise = new Promise((resolve, reject) => { _bridgeDataResolve = resolve; - _bridgeDataReject = reject; setTimeout(() => { _bridgeDataPromise = null; _bridgeDataResolve = null; - _bridgeDataReject = null; reject(new Error("Bridge data timeout")); }, timeoutMs); }); @@ -54,7 +50,7 @@ export class PxModRimRPC { constructor(channel: any) { this.bridge = channel.objects.bridge; - this.bridge.result_ready.connect((id: string, json: string) => { + this.bridge.resultReady.connect((id: string, json: string) => { const resolve = this.pending.get(id); if (resolve) { this.pending.delete(id); @@ -95,7 +91,9 @@ export function initRPC(): Promise { export function __pxmSetInstalled(modIds: string[]): void { window.__pxmodrim.installedIds.clear(); - (modIds || []).forEach((id) => window.__pxmodrim.installedIds.add(id)); + (modIds || []).forEach((id) => { + window.__pxmodrim.installedIds.add(id); + }); if (_bridgeDataReady) { updateAllModBadges(); refreshAllDepsBadges(); @@ -119,7 +117,9 @@ export function __pxmClearChecked(): void { export function __pxmSetActive(modIds: string[]): void { window.__pxmodrim.activeIds.clear(); - (modIds || []).forEach((id) => window.__pxmodrim.activeIds.add(id)); + (modIds || []).forEach((id) => { + window.__pxmodrim.activeIds.add(id); + }); if (_bridgeDataReady) { updateAllModBadges(); refreshAllDepsBadges(); diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts b/src/ui/plugins/steam_workshop/ts/collection.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/collection.ts rename to src/ui/plugins/steam_workshop/ts/collection.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts b/src/ui/plugins/steam_workshop/ts/controls.ts similarity index 99% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts rename to src/ui/plugins/steam_workshop/ts/controls.ts index d0a0a97..2c7a1ac 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/controls.ts +++ b/src/ui/plugins/steam_workshop/ts/controls.ts @@ -1,5 +1,5 @@ import { ModState, type ModStatus } from "./types"; -import { log, warn } from "./utils"; +import { warn } from "./utils"; import { getDepsFor, flattenDepTree } from "./deps"; import { PxModRimAPI } from "./api"; diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts b/src/ui/plugins/steam_workshop/ts/deps.ts similarity index 97% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts rename to src/ui/plugins/steam_workshop/ts/deps.ts index 37a30e4..c12c425 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/deps.ts +++ b/src/ui/plugins/steam_workshop/ts/deps.ts @@ -1,4 +1,4 @@ -import type { DepNode, DepItemsResult } from "./types"; +import type { DepNode } from "./types"; import { Config } from "./types"; import { log, warn, parseHTML, scrapeDepsFromContainer } from "./utils"; import { PxModRimAPI } from "./api"; @@ -149,7 +149,9 @@ async function buildDomDepTree( const resolvers = _fetchResolvers.get(modId); if (resolvers) { _fetchResolvers.delete(modId); - resolvers.forEach((r) => r()); + resolvers.forEach((r) => { + r(); + }); } } } @@ -210,7 +212,9 @@ export function flattenDepTree( } export function cancelPendingDepFetches(): void { - DepState.aborters.forEach((controller) => controller.abort()); + DepState.aborters.forEach((controller) => { + controller.abort(); + }); DepState.aborters.clear(); DepState.fetching.clear(); } diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts b/src/ui/plugins/steam_workshop/ts/detail.ts similarity index 98% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts rename to src/ui/plugins/steam_workshop/ts/detail.ts index b5e0766..fd09f59 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/detail.ts +++ b/src/ui/plugins/steam_workshop/ts/detail.ts @@ -1,4 +1,4 @@ -import { ModState, Config } from "./types"; +import { Config } from "./types"; import { log, warn } from "./utils"; import { getModState, applyModState, handleClick, performModAction } from "./controls"; import { getDepsFor } from "./deps"; @@ -121,7 +121,7 @@ export async function createDetailButton(): Promise { if ((createDetailButton as Record)._running) return; (createDetailButton as Record)._running = true; try { - log("createDetailButton ENTER url=" + window.location.href); + log(`createDetailButton ENTER url=${window.location.href}`); const currentUrl = window.location.href; if (DetailState.injectedUrl !== currentUrl) { DetailState.injectedUrl = currentUrl; diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts b/src/ui/plugins/steam_workshop/ts/globals.d.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/globals.d.ts rename to src/ui/plugins/steam_workshop/ts/globals.d.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts b/src/ui/plugins/steam_workshop/ts/lifecycle.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/lifecycle.ts rename to src/ui/plugins/steam_workshop/ts/lifecycle.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/main.ts b/src/ui/plugins/steam_workshop/ts/main.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/main.ts rename to src/ui/plugins/steam_workshop/ts/main.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js b/src/ui/plugins/steam_workshop/ts/qwebchannel.js similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/qwebchannel.js rename to src/ui/plugins/steam_workshop/ts/qwebchannel.js diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts b/src/ui/plugins/steam_workshop/ts/rendering.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/rendering.ts rename to src/ui/plugins/steam_workshop/ts/rendering.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json b/src/ui/plugins/steam_workshop/ts/tsconfig.json similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/tsconfig.json rename to src/ui/plugins/steam_workshop/ts/tsconfig.json diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/types.ts b/src/ui/plugins/steam_workshop/ts/types.ts similarity index 100% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/types.ts rename to src/ui/plugins/steam_workshop/ts/types.ts diff --git a/src/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts b/src/ui/plugins/steam_workshop/ts/utils.ts similarity index 94% rename from src/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts rename to src/ui/plugins/steam_workshop/ts/utils.ts index 428dd3a..324077c 100644 --- a/src/pxmodrim/ui/plugins/steam_workshop/ts/utils.ts +++ b/src/ui/plugins/steam_workshop/ts/utils.ts @@ -47,8 +47,7 @@ export function scrapeDepsFromContainer(containerEl: Element): ModToggle[] { 'a[href*="filedetails/?id="]', ); links.forEach((link) => { - const match = link.href.match(/[?&]id=(\d+)/); - const modId = match ? match[1] : null; + const modId = getModId(link); const titleEl = link.querySelector(".requiredItem"); const title = titleEl ? titleEl.textContent?.trim() diff --git a/src/ui/plugins/steam_workshop/view.cpp b/src/ui/plugins/steam_workshop/view.cpp new file mode 100644 index 0000000..9f76dfc --- /dev/null +++ b/src/ui/plugins/steam_workshop/view.cpp @@ -0,0 +1,218 @@ +#include "ui/plugins/steam_workshop/view.hpp" + +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ui/plugins/steam_workshop/bridge_obj.hpp" +#include "ui/plugins/steam_workshop/download_sidebar.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +namespace { +const QUrl kWorkshopUrl( + QStringLiteral("https://steamcommunity.com/workshop/browse/?appid=294100")); + +QString jsonArgument(const QJsonValue& value) { + const auto json = + QJsonDocument(QJsonArray{value}).toJson(QJsonDocument::Compact); + return QString::fromUtf8(json.constData() + 1, json.size() - 2); +} + +QString injection() { + QFile js( + QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.js")); + if (!js.open(QIODevice::ReadOnly)) { + spdlog::warn( + "Steam Workshop injection bundle is absent; browser download controls " + "are unavailable"); + return {}; + } + QFile css( + QStringLiteral(":/qt/qml/PxModRim/plugins/steam_workshop/inject.css")); + QString styles; + if (css.open(QIODevice::ReadOnly)) styles = QString::fromUtf8(css.readAll()); + return QStringLiteral("var CSS_STYLES = %1;\n%2") + .arg(jsonArgument(styles), QString::fromUtf8(js.readAll())); +} +} // namespace + +SteamWorkshopViewPanel::SteamWorkshopViewPanel(CoreContext& ctx, + QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app) { + auto* content = new QWidget(this); + auto* layout = new QHBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + m_sidebar = new DownloadSidebar(engine, content); + layout->addWidget(m_sidebar); + m_qml = new QQuickWidget(engine, content); + m_qml->setObjectName(QStringLiteral("workshopView")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(paletteColor(QStringLiteral("ELEVATE_0"))); +#ifdef PX_HAS_WEBENGINE + layout->addWidget(m_qml, 1); +#else + m_qml->hide(); + auto* unavailable = new QLabel( + QStringLiteral( + "This build does not include the Steam Workshop web browser."), + content); + unavailable->setAlignment(Qt::AlignCenter); + unavailable->setWordWrap(true); + layout->addWidget(unavailable, 1); +#endif + m_root->addWidget(content, 1); +} + +SteamWorkshopViewPanel::~SteamWorkshopViewPanel() { teardown(); } + +void SteamWorkshopViewPanel::ensureInitialized() { + if (m_initialized) return; + m_initialized = true; + m_plugin = static_cast( + m_app.plugins().get(QStringLiteral("steamworkshop"))); + if (m_plugin) { + connect(m_sidebar, &DownloadSidebar::downloadTriggered, this, + [this] { m_plugin->requestDownload(); }); + connect(m_sidebar, &DownloadSidebar::stopTriggered, this, + [this] { m_plugin->stopDownload(); }); + connect(m_sidebar, &DownloadSidebar::itemRemoved, this, + [this](const QString& id) { m_plugin->removeItem(id); }); + connect(m_sidebar, &DownloadSidebar::clearRequested, this, + [this] { m_plugin->clearQueue(); }); + m_badgesId = m_plugin->badgesRefreshRequested.connect( + [this](const QStringList& ids) { + runJs( + QStringLiteral( + "if (window.__pxmSetInstalled) window.__pxmSetInstalled(%1)") + .arg(jsonArgument(QJsonArray::fromStringList(ids)))); + }); + m_activeId = m_plugin->activeRefreshRequested.connect( + [this](const QStringList& ids) { + runJs(QStringLiteral( + "if (window.__pxmSetActive) window.__pxmSetActive(%1)") + .arg(jsonArgument(QJsonArray::fromStringList(ids)))); + }); + m_sidebarId = + m_plugin->sidebarSyncRequested.connect([this](const SidebarSync& sync) { + m_sidebar->syncFrom(sync.checkedIds, sync.statuses); + }); + m_statusId = m_plugin->itemStatusChanged.connect( + [this](const DownloadItemStatus& status) { + m_sidebar->updateStatus(status.modId, status.status, status.bytesDone, + status.bytesTotal); + }); + m_busyId = m_plugin->downloadBusyChanged.connect( + [this](bool busy) { m_sidebar->setDownloadEnabled(!busy); }); + m_uncheckId = + m_plugin->uncheckModRequested.connect([this](const QString& id) { + runJs(QStringLiteral( + "if (window.__pxmUncheckMod) window.__pxmUncheckMod(%1);") + .arg(jsonArgument(id))); + }); + m_clearId = + m_plugin->clearCheckedRequested.connect([this](const std::monostate&) { + runJs(QStringLiteral( + "if (window.__pxmClearChecked) window.__pxmClearChecked();")); + }); + m_sidebar->setDownloadEnabled(!m_plugin->isDownloading()); + m_plugin->syncAll(); + } +#ifdef PX_HAS_WEBENGINE + auto* bridge = m_plugin ? new PxModRimBridge(*m_plugin, m_qml) : nullptr; + if (bridge) bridge->setObjectName(QStringLiteral("workshopBridge")); + auto* context = m_qml->rootContext(); + context->setContextProperty(QStringLiteral("steamWorkshopPanel"), this); + context->setContextProperty(QStringLiteral("_bridge"), bridge); + context->setContextProperty(QStringLiteral("_injectCode"), injection()); + m_qml->setSource( + QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/SteamWorkshop.qml"))); + if (bridge) bridge->setPage(web()); +#endif +} + +QObject* SteamWorkshopViewPanel::web() const { +#ifdef PX_HAS_WEBENGINE + if (m_initialized && m_qml->rootObject()) + return m_qml->rootObject()->findChild( + QStringLiteral("workshopWeb")); +#endif + return nullptr; +} + +void SteamWorkshopViewPanel::runJs(const QString& code) { +#ifdef PX_HAS_WEBENGINE + if (auto* root = m_qml->rootObject()) + QMetaObject::invokeMethod(root, "runScript", Q_ARG(QVariant, code)); +#else + Q_UNUSED(code); +#endif +} + +void SteamWorkshopViewPanel::navigateHome() { + if (auto* browser = web()) browser->setProperty("url", kWorkshopUrl); +} + +bool SteamWorkshopViewPanel::isSteamOrigin(const QUrl& url) { + if (!url.isValid() || url.scheme() != QLatin1String("https")) return false; + const auto host = url.host(); + return host == QLatin1String("steamcommunity.com") || + host.endsWith(QLatin1String(".steamcommunity.com")); +} + +void SteamWorkshopViewPanel::navigateToUrl(const QString& text) { + if (text.isEmpty()) return; + auto url = QUrl::fromUserInput(text); + if (!url.isValid()) return; + if (!isSteamOrigin(url)) { + QDesktopServices::openUrl(url); + return; + } + if (auto* browser = web()) browser->setProperty("url", url); +} + +void SteamWorkshopViewPanel::preload() { ensureInitialized(); } +void SteamWorkshopViewPanel::showEvent(QShowEvent* event) { + BaseViewPanel::showEvent(event); + ensureInitialized(); +} + +void SteamWorkshopViewPanel::teardown() { + if (!m_initialized) return; + if (auto* browser = web()) { + QMetaObject::invokeMethod(browser, "stop"); + browser->setProperty("url", QUrl(QStringLiteral("about:blank"))); + } + m_qml->setSource({}); + if (auto* bridge = + m_qml->findChild(QStringLiteral("workshopBridge"))) + delete bridge; + disconnect(m_sidebar, nullptr, this, nullptr); + if (m_plugin) { + m_plugin->badgesRefreshRequested.disconnect(m_badgesId); + m_plugin->activeRefreshRequested.disconnect(m_activeId); + m_plugin->sidebarSyncRequested.disconnect(m_sidebarId); + m_plugin->itemStatusChanged.disconnect(m_statusId); + m_plugin->downloadBusyChanged.disconnect(m_busyId); + m_plugin->uncheckModRequested.disconnect(m_uncheckId); + m_plugin->clearCheckedRequested.disconnect(m_clearId); + } + m_plugin = nullptr; + m_initialized = false; +} + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/steam_workshop/view.hpp b/src/ui/plugins/steam_workshop/view.hpp new file mode 100644 index 0000000..96ba1eb --- /dev/null +++ b/src/ui/plugins/steam_workshop/view.hpp @@ -0,0 +1,53 @@ +#pragma once + +#ifdef PX_HAS_STEAM + +#include + +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/views/base.hpp" + +class QQuickWidget; +class QShowEvent; + +namespace px::modrim { + +class DownloadSidebar; + +class SteamWorkshopViewPanel final : public BaseViewPanel { + Q_OBJECT + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("steam_workshop"), + QLatin1StringView("steam"), + QLatin1StringView("Steam Workshop")}; + SteamWorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + ~SteamWorkshopViewPanel() override; + void preload(); + void teardown(); + Q_INVOKABLE void navigateHome(); + Q_INVOKABLE void navigateToUrl(const QString& text); + Q_INVOKABLE static bool isSteamOrigin(const QUrl& url); + + protected: + void showEvent(QShowEvent* event) override; + + private: + void ensureInitialized(); + QObject* web() const; + void runJs(const QString& code); + DownloadSidebar* m_sidebar; + QQuickWidget* m_qml; + SteamWorkshopUiPlugin* m_plugin = nullptr; + bool m_initialized = false; + Event::Id m_badgesId = 0; + Event::Id m_activeId = 0; + Event::Id m_sidebarId = 0; + Event::Id m_statusId = 0; + Event::Id m_busyId = 0; + Event::Id m_uncheckId = 0; + Event::Id m_clearId = 0; +}; + +} // namespace px::modrim +#endif diff --git a/src/ui/plugins/workshop/details.cpp b/src/ui/plugins/workshop/details.cpp new file mode 100644 index 0000000..09eac7a --- /dev/null +++ b/src/ui/plugins/workshop/details.cpp @@ -0,0 +1,82 @@ +#include "ui/plugins/workshop/details.hpp" + +namespace px::modrim::workshop { +namespace { +QString joined(const std::vector& ids) { + QStringList values; + values.reserve(static_cast(ids.size())); + for (const auto& id : ids) values.append(QString::fromStdString(id)); + return values.join(QStringLiteral(", ")); +} +} // namespace + +QCoro::Task> fetchDetail( + workshop::WorkshopCatalog& catalog, QString itemId, QString kind) { + using namespace workshop; + if (kind == QStringLiteral("collection")) { + auto result = co_await catalog.collection(itemId); + if (!result) co_return std::unexpected(result.error()); + if (!*result) + co_return std::unexpected(CatalogError{ + QStringLiteral("This collection is no longer available."), 404}); + auto& value = **result; + QString warning; + if (!value.is_complete) + warning = QStringLiteral( + "This collection is incomplete. Some members are unavailable or not " + "listed."); + if (!value.unavailable_ids.empty()) + warning += + QStringLiteral(" Unavailable: ") + joined(value.unavailable_ids); + co_return Detail{std::move(value.collection), std::move(value.members), + warning.toStdString(), value.is_complete}; + } + auto result = co_await catalog.mod(itemId); + if (!result) co_return std::unexpected(result.error()); + if (!*result) + co_return std::unexpected( + CatalogError{QStringLiteral("This mod is no longer available."), 404}); + auto mod = std::move(**result); + std::vector members; + QStringList missing; + // Start every dependency request before waiting for the first one. + std::vector>>> tasks; + tasks.reserve(mod.dependencies.size()); + for (const auto& id : mod.dependencies) + tasks.push_back(catalog.mod(QString::fromStdString(id))); + for (std::size_t index = 0; index < tasks.size(); ++index) { + auto dependency = co_await tasks[index]; + if (!dependency) co_return std::unexpected(dependency.error()); + if (*dependency) + members.emplace_back(std::move(**dependency)); + else + missing.append(QString::fromStdString(mod.dependencies[index])); + } + const auto warning = missing.isEmpty() + ? QString{} + : QStringLiteral("Unavailable dependencies: ") + + missing.join(QStringLiteral(", ")); + co_return Detail{std::move(mod), std::move(members), warning.toStdString(), + missing.isEmpty()}; +} + +QString incompletePlanNotice(const workshop::DownloadPlan& plan) { + QStringList parts{QStringLiteral("The download plan is incomplete.")}; + if (!plan.unavailable_ids.isEmpty()) + parts.append(QStringLiteral("Unavailable: ") + + plan.unavailable_ids.join(QStringLiteral(", "))); + if (!plan.incomplete_collection_ids.isEmpty()) + parts.append(QStringLiteral("Incomplete collections: ") + + plan.incomplete_collection_ids.join(QStringLiteral(", "))); + return parts.join(QLatin1Char(' ')); +} + +QString queuedNotice(qsizetype count) { + return QStringLiteral( + "Queued %1 mod%2. Progress is shown in the header and results in " + "Downloads. Mods are not activated.") + .arg(count) + .arg(count == 1 ? QString{} : QStringLiteral("s")); +} + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/details.hpp b/src/ui/plugins/workshop/details.hpp new file mode 100644 index 0000000..dbd5e51 --- /dev/null +++ b/src/ui/plugins/workshop/details.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "core/workshop/catalog.hpp" + +namespace px::modrim::workshop { + +struct Detail { + workshop::CatalogEntry item; + std::vector members; + std::string warning; + bool complete = false; + + bool operator==(const Detail&) const = default; +}; + +QCoro::Task> fetchDetail( + workshop::WorkshopCatalog& catalog, QString itemId, QString kind); +QString incompletePlanNotice(const workshop::DownloadPlan& plan); +QString queuedNotice(qsizetype count); + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/installed.cpp b/src/ui/plugins/workshop/installed.cpp new file mode 100644 index 0000000..039f981 --- /dev/null +++ b/src/ui/plugins/workshop/installed.cpp @@ -0,0 +1,60 @@ +#include "ui/plugins/workshop/installed.hpp" + +#include +#include +#include + +namespace px::modrim::workshop { + +void InstalledList::replace(std::vector mods) { + m_mods = std::move(mods); + rematch(); +} + +void InstalledList::search(QString query) { + m_query = std::move(query); + resetPaging(); + rematch(); +} + +void InstalledList::resetPaging() { m_shown = kPage; } + +std::vector InstalledList::visible() const { + return slice(0, std::min(m_shown, m_matches.size())); +} + +std::vector InstalledList::nextPage() { + const auto start = m_shown; + m_shown = std::min(start + kPage, m_matches.size()); + return slice(start, m_shown); +} + +std::vector InstalledList::slice(std::size_t start, + std::size_t end) const { + std::vector result; + if (start >= end) return result; + result.reserve(end - start); + for (auto i = start; i < end; ++i) result.push_back(m_mods[m_matches[i]]); + return result; +} + +void InstalledList::rematch() { + m_matches.clear(); + const QString needle = m_query.toCaseFolded(); + for (std::size_t i = 0; i < m_mods.size(); ++i) { + const auto& mod = m_mods[i]; + if (needle.isEmpty()) { + m_matches.push_back(i); + continue; + } + QStringList fields{ + QString::fromStdString(mod.title), + mod.author.name ? QString::fromStdString(*mod.author.name) : QString{}, + QString::fromStdString(mod.id)}; + for (const auto& tag : mod.tags) fields.append(QString::fromStdString(tag)); + if (fields.join(' ').toCaseFolded().contains(needle)) + m_matches.push_back(i); + } +} + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/installed.hpp b/src/ui/plugins/workshop/installed.hpp new file mode 100644 index 0000000..1390ba4 --- /dev/null +++ b/src/ui/plugins/workshop/installed.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#include "core/workshop/types.hpp" + +namespace px::modrim::workshop { + +inline constexpr int kPage = 48; + +class InstalledList { + public: + const std::vector& mods() const { return m_mods; } + bool loaded() const { return !m_mods.empty(); } + int total() const { return static_cast(m_matches.size()); } + bool hasMore() const { return m_shown < m_matches.size(); } + + void replace(std::vector mods); + void search(QString query); + void resetPaging(); + std::vector visible() const; + std::vector nextPage(); + + private: + void rematch(); + std::vector slice(std::size_t start, + std::size_t end) const; + + std::vector m_mods; + std::vector m_matches; + QString m_query; + std::size_t m_shown = kPage; +}; + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.cpp b/src/ui/plugins/workshop/models.cpp new file mode 100644 index 0000000..9ac98ec --- /dev/null +++ b/src/ui/plugins/workshop/models.cpp @@ -0,0 +1,303 @@ +#include "ui/plugins/workshop/models.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ui/panels/mod_info_data.hpp" + +namespace px::modrim::workshop { +namespace { +using namespace workshop; + +constexpr std::array kRoles = { + "itemId", "kind", "title", "author", "previewUrl", + "collage", "sourceLabel", "versions", "tags", "state", + "active", "stateLabel", "actionLabel", "incompatible", "memberCount", + "description", "workshopUrl", "fileSize", "votes", "votesUp", + "votesDown", "queued", "favourite", "summary", "compatLabel"}; + +QString sizeLabel(const std::optional& bytes) { + return bytes ? QString::number( + QString::fromStdString(*bytes).toDouble() / 1048576.0, 'f', + 1) + + " MB" + : QStringLiteral("Unknown"); +} + +QString compatLabel(const QSet& versions, const QString& game, + bool incompatible, bool noCommon) { + if (game.isEmpty()) return {}; + if (noCommon) return QStringLiteral("No common version"); + if (incompatible) return "Not for " + game; + if (versions.isEmpty()) return QStringLiteral("Version not stated"); + return "Works with " + game; +} +} // namespace + +QString safeUrl(const QString& value) { + for (const QChar ch : value) { + if (ch.isSpace() || ch.unicode() < 32) return {}; + } + const QUrl url(value); + const QString scheme = url.scheme().toLower(); + return url.isValid() && (scheme == "http" || scheme == "https") && + !url.authority().isEmpty() + ? value + : QString{}; +} + +QString summarize(const QString& text, const QString& format) { + static const QRegularExpression media( + QStringLiteral(R"(\[(img|video|previewimage)[^\]]*\].*?\[/\1\])"), + QRegularExpression::CaseInsensitiveOption | + QRegularExpression::DotMatchesEverythingOption); + static const QRegularExpression tags( + QStringLiteral(R"(\[/?[a-zA-Z*][^\]]*\])")); + QString result = text; + if (format == "bbcode") result.replace(media, " ").replace(tags, " "); + result = result.simplified(); + const auto codepoints = result.toUcs4(); + if (codepoints.size() <= 140) return result; + result = QString::fromUcs4(codepoints.constData(), 140); + const auto space = result.lastIndexOf(' '); + if (space >= 0) result.truncate(space); + const QString punctuation = QStringLiteral(".,;:-–— "); + while (!result.isEmpty() && punctuation.contains(result.back())) + result.chop(1); + return result + QChar(0x2026); +} + +QVariantMap itemRow( + const CatalogEntry& entry, + const std::function& installState, + const QString& gameVersion, bool detail) { + return std::visit( + [&](const auto& item) { + using Item = std::decay_t; + constexpr bool kCollection = std::is_same_v; + QSet versions; + for (const auto& version : item.supported_versions) + versions.insert(QString::fromStdString(version)); + if constexpr (kCollection) { + static const QRegularExpression versionTag( + QStringLiteral(R"(^\d+\.\d+(?:\.\d+)?$)")); + for (const auto& tag : item.tags) { + const QString value = QString::fromStdString(tag); + if (versionTag.match(value).hasMatch()) versions.insert(value); + } + } + bool incompatible = !gameVersion.isEmpty() && !versions.isEmpty() && + !versions.contains(gameVersion); + bool noCommon = false; + InstallState state = InstallState::kMissing; + QString source, action, size, votes = QStringLiteral("Unrated"), + up = "0", down = "0"; + QString workshopUrl, format = QStringLiteral("bbcode"); + QStringList collage; + qlonglong memberCount = 0; + if constexpr (kCollection) { + source = item.source == CollectionSource::kPicked + ? "PxModRim pick" + : "Steam collection"; + action = QStringLiteral("Download all"); + memberCount = item.member_count; + size = sizeLabel(item.total_size); + noCommon = item.no_common_version && versions.isEmpty(); + incompatible = incompatible || noCommon; + if (item.workshop_url) + workshopUrl = QString::fromStdString(*item.workshop_url); + if (item.description_format == CollectionDescriptionFormat::kText) + format = QStringLiteral("text"); + for (const auto& preview : item.member_previews) { + const QString url = safeUrl(QString::fromStdString(preview)); + if (!url.isEmpty()) collage.append(url); + } + } else { + state = installState(item); + incompatible = incompatible || item.incompatible; + source = QStringLiteral("Steam Workshop"); + switch (state) { + case InstallState::kMissing: + action = QStringLiteral("Download"); + break; + case InstallState::kInstalled: + action = QStringLiteral("Installed"); + break; + case InstallState::kOutdated: + action = QStringLiteral("Update"); + break; + } + size = sizeLabel(item.file_size); + workshopUrl = QString::fromStdString(item.workshop_url); + if (item.votes) { + const QLocale locale(QLocale::English, QLocale::UnitedStates); + up = locale.toString(static_cast(item.votes->up)); + down = locale.toString(static_cast(item.votes->down)); + const double total = static_cast(item.votes->up) + + static_cast(item.votes->down); + if (total != 0) + votes = + QString::number( + std::nearbyint(100.0 * item.votes->up / total), 'f', 0) + + '%'; + } + } + QString stateName, stateLabel; + switch (state) { + case InstallState::kMissing: + stateName = "missing"; + stateLabel = "Not installed"; + break; + case InstallState::kInstalled: + stateName = "installed"; + stateLabel = "Installed"; + break; + case InstallState::kOutdated: + stateName = "outdated"; + stateLabel = "Update available"; + break; + } + QString preview = item.preview_url + ? QString::fromStdString(*item.preview_url) + : QString{}; + if (preview.isEmpty()) { + for (const auto& image : item.previews) { + if (image.type == PreviewType::kImage) { + preview = QString::fromStdString(image.url); + break; + } + } + } + QString author; + if (item.author.name) + author = QString::fromStdString(*item.author.name); + if (author.isEmpty() && item.author.id) + author = QString::fromStdString(*item.author.id); + if (author.isEmpty()) author = QStringLiteral("Unknown author"); + std::vector, QString>> sorted; + sorted.reserve(versions.size()); + for (const auto& version : versions) { + std::vector parts; + for (const auto& part : version.split('.')) + parts.push_back(part.toInt()); + sorted.emplace_back(std::move(parts), version); + } + std::sort(sorted.begin(), sorted.end()); + QStringList versionLabels, tagLabels; + for (const auto& version : sorted) versionLabels.append(version.second); + for (const auto& tag : item.tags) { + const QString value = QString::fromStdString(tag); + if (!versions.contains(value)) tagLabels.append(value); + } + const QString description = QString::fromStdString(item.description); + return QVariantMap{ + {"itemId", QString::fromStdString(item.id)}, + {"kind", kCollection ? "collection" : "mod"}, + {"title", QString::fromStdString(item.title)}, + {"author", author}, + {"previewUrl", safeUrl(preview)}, + {"collage", collage}, + {"sourceLabel", source}, + {"versions", versionLabels.isEmpty() + ? QStringLiteral("Not specified") + : versionLabels.join(", ")}, + {"tags", tagLabels.join(QStringLiteral(" · "))}, + {"state", stateName}, + {"active", false}, + {"queued", false}, + {"favourite", false}, + {"stateLabel", stateLabel}, + {"actionLabel", action}, + {"incompatible", incompatible}, + {"memberCount", memberCount}, + {"description", + detail ? px::modrim::descriptionToHtml(description, format) + : QString{}}, + {"workshopUrl", safeUrl(workshopUrl)}, + {"fileSize", size}, + {"votes", votes}, + {"votesUp", up}, + {"votesDown", down}, + {"summary", summarize(description, format)}, + {"compatLabel", + compatLabel(versions, gameVersion, incompatible, noCommon)}}; + }, + entry); +} + +CatalogListModel::CatalogListModel(QObject* parent) + : QAbstractListModel(parent) {} + +void CatalogListModel::setPage(QList rows, int total, + std::optional cursor, bool append) { + if (append) { + QSet known; + for (const auto& row : m_rows) known.insert(row.value("itemId").toString()); + QList additions; + for (auto& row : rows) { + const QString id = row.value("itemId").toString(); + if (!known.contains(id)) { + known.insert(id); + additions.append(std::move(row)); + } + } + if (!additions.isEmpty()) { + beginInsertRows({}, count(), + count() + static_cast(additions.size()) - 1); + m_rows.append(std::move(additions)); + endInsertRows(); + } + } else if (rows.size() == m_rows.size() && + std::equal(rows.cbegin(), rows.cend(), m_rows.cbegin(), + [](const auto& a, const auto& b) { + return a.value("itemId") == b.value("itemId"); + })) { + for (qsizetype i = 0; i < rows.size(); ++i) { + if (rows.at(i) != m_rows.at(i)) { + m_rows[i] = std::move(rows[i]); + emit dataChanged(index(static_cast(i)), + index(static_cast(i))); + } + } + } else { + beginResetModel(); + m_rows = std::move(rows); + endResetModel(); + } + m_total = total; + m_cursor = std::move(cursor); + emit changed(); +} + +void CatalogListModel::refreshRows( + const std::function& convert) { + for (auto& row : m_rows) row = convert(row); + if (!m_rows.isEmpty()) emit dataChanged(index(0), index(count() - 1)); +} + +int CatalogListModel::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : count(); +} + +QVariant CatalogListModel::data(const QModelIndex& index, int role) const { + const int offset = role - Qt::UserRole - 1; + if (!index.isValid() || index.row() < 0 || index.row() >= count() || + offset < 0 || offset >= static_cast(kRoles.size())) + return {}; + return m_rows.at(index.row()).value(QString::fromLatin1(kRoles[offset])); +} + +QHash CatalogListModel::roleNames() const { + QHash names; + for (std::size_t i = 0; i < kRoles.size(); ++i) + names.insert(Qt::UserRole + static_cast(i) + 1, kRoles[i]); + return names; +} + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/models.hpp b/src/ui/plugins/workshop/models.hpp new file mode 100644 index 0000000..ac5ac6f --- /dev/null +++ b/src/ui/plugins/workshop/models.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/workshop/types.hpp" + +namespace px::modrim::workshop { + +using workshop::CatalogEntry; + +QString safeUrl(const QString& value); +QString summarize(const QString& text, + const QString& format = QStringLiteral("bbcode")); +QVariantMap itemRow( + const CatalogEntry& item, + const std::function& + installState, + const QString& gameVersion, bool detail = false); + +class CatalogListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(bool hasMore READ hasMore NOTIFY changed) + Q_PROPERTY(int count READ count NOTIFY changed) + Q_PROPERTY(int total READ total NOTIFY changed) + + public: + explicit CatalogListModel(QObject* parent = nullptr); + + void setPage(QList rows, int total, + std::optional cursor, bool append = false); + void refreshRows( + const std::function& convert); + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, + int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + const std::optional& cursor() const { return m_cursor; } + bool hasMore() const { return m_cursor.has_value(); } + int count() const { return static_cast(m_rows.size()); } + int total() const { return m_total; } + const QList& rows() const { return m_rows; } + + signals: + void changed(); + + private: + QList m_rows; + std::optional m_cursor; + int m_total = 0; +}; + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.cpp b/src/ui/plugins/workshop/plugin.cpp new file mode 100644 index 0000000..e3b875e --- /dev/null +++ b/src/ui/plugins/workshop/plugin.cpp @@ -0,0 +1,38 @@ +#include "ui/plugins/workshop/plugin.hpp" + +#include "ui/plugins/workshop/view.hpp" + +namespace px::modrim::workshop { + +CatalogSettingsSection::CatalogSettingsSection( + workshop::WorkshopCatalog& catalog, QObject* parent) + : PluginConfigSectionBase( + {{QStringLiteral("url"), + QString::fromStdString(catalog.settings().url)}}, + parent), + m_catalog(catalog) {} +QString CatalogSettingsSection::title() const { + return QStringLiteral("Workshop catalog"); +} +QUrl CatalogSettingsSection::source() const { + return QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/CatalogSettings.qml")); +} +Result CatalogSettingsSection::commit(const QJsonObject& merged) { + m_catalog.setBaseUrl(merged.value(QStringLiteral("url")).toString()); + return {}; +} + +WorkshopUiPlugin::WorkshopUiPlugin() + : UiPlugin( + QStringLiteral("workshop_ui"), + {QStringLiteral("workshop_catalog"), QStringLiteral("downloads")}) {} +void WorkshopUiPlugin::setup(AppContext& ctx) { + ctx.addRailView(); + auto* catalog = static_cast( + ctx.coreContext().plugins().get(QStringLiteral("workshop_catalog"))); + ctx.addSettingsSection([catalog](QObject* parent) { + return new CatalogSettingsSection(*catalog, parent); + }); +} + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/plugin.hpp b/src/ui/plugins/workshop/plugin.hpp new file mode 100644 index 0000000..2c056e9 --- /dev/null +++ b/src/ui/plugins/workshop/plugin.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "core/workshop/catalog.hpp" +#include "ui/context.hpp" +#include "ui/settings_section.hpp" + +namespace px::modrim::workshop { + +class CatalogSettingsSection : public PluginConfigSectionBase { + public: + CatalogSettingsSection(workshop::WorkshopCatalog& catalog, QObject* parent); + QString title() const override; + QUrl source() const override; + + protected: + Result commit(const QJsonObject& merged) override; + + private: + workshop::WorkshopCatalog& m_catalog; +}; + +class WorkshopUiPlugin : public UiPlugin { + public: + WorkshopUiPlugin(); + void setup(AppContext& ctx) override; +}; + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/view.cpp b/src/ui/plugins/workshop/view.cpp new file mode 100644 index 0000000..c44a977 --- /dev/null +++ b/src/ui/plugins/workshop/view.cpp @@ -0,0 +1,678 @@ +#include "ui/plugins/workshop/view.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ui/components/mod_activation.hpp" +#include "ui/navigation.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim::workshop { +using namespace workshop; +namespace { +QString idOf(const CatalogEntry& item) { + return std::visit( + [](const auto& value) { return QString::fromStdString(value.id); }, item); +} +QSet idsOf(const std::vector& ids) { + QSet result; + for (const auto& id : ids) result.insert(QString::fromStdString(id)); + return result; +} +std::optional cursorOf(const std::optional& cursor) { + return cursor ? std::optional(QString::fromStdString(*cursor)) : std::nullopt; +} +WorkshopCatalog& catalogOf(CoreContext& ctx) { + return *static_cast( + ctx.plugins().get(QStringLiteral("workshop_catalog"))); +} +} // namespace + +WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : WorkshopViewPanel(ctx, engine, parent, app, catalogOf(ctx)) {} + +WorkshopViewPanel::WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app, + WorkshopCatalog& catalog) + : BaseViewPanel(ctx, engine, parent, app), + modsModel(new CatalogListModel(this)), + collectionsModel(new CatalogListModel(this)), + membersModel(new CatalogListModel(this)), + m_catalog(catalog), + m_downloads(ctx.downloads()), + m_favourites(catalog.favourites()) { + if (!gameVersion().isEmpty()) m_criteria.version = gameVersion(); + refreshModIds(); + m_qml = new QQuickWidget(engine, this); + m_qml->setObjectName(QStringLiteral("workshopView")); + m_qml->setResizeMode(QQuickWidget::SizeRootObjectToView); + m_qml->setClearColor(px::modrim::paletteColor(u"ELEVATE_0")); + m_qml->rootContext()->setContextProperty(QStringLiteral("workshopPanel"), + this); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMods"), + modsModel); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogCollections"), + collectionsModel); + m_qml->rootContext()->setContextProperty(QStringLiteral("catalogMembers"), + membersModel); + m_busyId = m_downloads.busyChanged.connect([this](bool) { + refreshRows(); + emit changed(); + }); + m_queueId = m_catalog.queueChanged.connect([this](const std::monostate&) { + refreshRows(); + emit changed(); + }); + m_urlId = m_catalog.catalogUrlChanged.connect( + [this](const QString&) { urlChanged(); }); + m_installedId = m_catalog.installedChanged.connect( + [this](const std::monostate&) { installedChanged(); }); + m_favouritesId = + m_favourites.changed().connect([this](const FavouritesState&) { + if (m_tab == QStringLiteral("Favourites") && !m_detailData) + showFavourites(); + else { + refreshRows(); + emit changed(); + } + }); + m_activeId = m_ctx.activeStateChanged().connect([this](const QStringList&) { + refreshModIds(); + refreshRows(); + emit changed(); + }); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); + m_root->addWidget(m_qml, 1); +} + +WorkshopViewPanel::~WorkshopViewPanel() { + teardown(); + // Workshop.qml bindings read workshopPanel; the view must die while this + // panel is still alive. + delete m_qml; +} +void WorkshopViewPanel::teardown() { + if (m_tornDown) return; + m_tornDown = true; + ++m_generation; + m_retry = {}; + m_downloads.busyChanged.disconnect(m_busyId); + m_catalog.queueChanged.disconnect(m_queueId); + m_catalog.catalogUrlChanged.disconnect(m_urlId); + m_catalog.installedChanged.disconnect(m_installedId); + m_favourites.changed().disconnect(m_favouritesId); + m_ctx.activeStateChanged().disconnect(m_activeId); +} + +QString WorkshopViewPanel::gameVersion() const { + const auto version = m_ctx.targetVersion(); + return version == QStringLiteral("Unknown") ? QString{} : version; +} +void WorkshopViewPanel::refreshModIds() { + m_installedIds.clear(); + m_activeIds.clear(); + const auto& mods = m_ctx.allMods(); + for (const auto& mod : mods) + if (auto id = mod->publishedFileId()) m_installedIds.insert(*id); + for (const auto& uuid : m_ctx.activeUuids()) { + auto mod = mods.value(uuid); + if (mod) + if (auto id = mod->publishedFileId()) m_activeIds.insert(*id); + } +} + +QVariantMap WorkshopViewPanel::row(const CatalogEntry& item, bool detail) { + const auto id = idOf(item); + m_items.insert(id, item); + std::visit( + [this](const auto& value) { + auto tags = idsOf(value.tags); + tags.subtract(idsOf(value.supported_versions)); + m_knownTags.unite(tags); + }, + item); + auto result = itemRow( + item, + [this](const CatalogMod& mod) { return m_catalog.installState(mod); }, + gameVersion(), detail); + const auto* mod = std::get_if(&item); + const auto* collection = std::get_if(&item); + result[QStringLiteral("active")] = mod && m_activeIds.contains(id); + if (collection) { + if (!detail) { + qsizetype count = 0; + for (const auto& member : collection->member_ids) + count += m_installedIds.contains(QString::fromStdString(member)); + if (!collection->member_ids.empty() && + count == static_cast(collection->member_ids.size())) { + result[QStringLiteral("state")] = QStringLiteral("installed"); + result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); + result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); + } else if (count) + result[QStringLiteral("actionLabel")] = + QStringLiteral("Complete download"); + } + if (m_favourites.contains(collection->id)) { + result[QStringLiteral("favourite")] = true; + m_favourites.remember(*collection); + } + } + if (collection || result.value(QStringLiteral("state")).toString() != + QStringLiteral("installed")) { + if (m_preparing.contains(id)) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Preparing…"); + } else { + QSet ids = + mod ? QSet{id} + : idsOf(collection->member_ids) | m_collectionMods.value(id); + if (collection && m_detailData && idOf(m_detailData->item) == id) + ids.unite(m_packIds); + if (ids.intersects(m_downloads.activeIds())) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Downloading"); + } else if (ids.intersects(m_catalog.queuedIds())) { + result[QStringLiteral("queued")] = true; + result[QStringLiteral("actionLabel")] = QStringLiteral("Queued"); + } + } + } + return result; +} + +QVariantMap WorkshopViewPanel::detailRow(const Detail& detail) { + auto result = row(detail.item, true); + result[QStringLiteral("complete")] = detail.complete; + result[QStringLiteral("warning")] = QString::fromStdString(detail.warning); + if (std::holds_alternative(detail.item)) { + auto collage = result.value(QStringLiteral("collage")).toStringList(); + if (result.value(QStringLiteral("previewUrl")).toString().isEmpty() && + collage.isEmpty()) { + for (const auto& member : detail.members) { + const auto* mod = std::get_if(&member); + if (!mod) continue; + QString preview = QString::fromStdString(mod->preview_url.value_or("")); + if (preview.isEmpty()) + for (const auto& image : mod->previews) { + if (image.type == PreviewType::kImage) { + preview = QString::fromStdString(image.url); + break; + } + } + preview = safeUrl(preview); + if (!preview.isEmpty()) collage.append(preview); + if (collage.size() == 4) break; + } + result[QStringLiteral("collage")] = collage; + } + bool started = false; + for (const auto& member : detail.members) + if (auto* mod = std::get_if(&member)) + started |= m_catalog.installState(*mod) != InstallState::kMissing; + if (!result.value(QStringLiteral("queued")).toBool()) + result[QStringLiteral("actionLabel")] = + started ? QStringLiteral("Complete download") + : QStringLiteral("Download all"); + if (auto copies = packCopies()) { + result[QStringLiteral("state")] = QStringLiteral("installed"); + result[QStringLiteral("stateLabel")] = QStringLiteral("Installed"); + if (!result.value(QStringLiteral("queued")).toBool()) + result[QStringLiteral("actionLabel")] = QStringLiteral("Installed"); + result[QStringLiteral("active")] = + std::ranges::all_of(*copies, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); + } + } + return result; +} +void WorkshopViewPanel::refreshRows() { + for (auto* model : {modsModel, collectionsModel, membersModel}) + model->refreshRows([this](const QVariantMap& value) { + return row( + m_items.value(value.value(QStringLiteral("itemId")).toString())); + }); + if (m_detailData) m_detail = detailRow(*m_detailData); +} +void WorkshopViewPanel::clearDetail() { + m_detail.clear(); + m_detailData.reset(); + m_current.reset(); + m_trail.clear(); + m_packIds.clear(); +} +std::optional WorkshopViewPanel::packCopies() const { + if (m_tornDown || m_packIds.isEmpty()) return std::nullopt; + QHash installed; + for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) + if (auto id = it.value()->publishedFileId(); id && m_packIds.contains(*id)) + installed[*id].append(it.key()); + if (installed.size() < m_packIds.size()) return std::nullopt; + QStringList copies; + for (const auto& uuids : installed) { + auto active = std::ranges::find_if(uuids, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); + copies.append(active == uuids.cend() ? uuids.front() : *active); + } + return copies; +} +QCoro::Task WorkshopViewPanel::loadPack() { + if (!m_detailData || m_tornDown) co_return; + QPointer self(this); + const int generation = m_generation; + const QStringList collectionIds{idOf(m_detailData->item)}; + auto result = co_await m_catalog.plan(QStringList{}, collectionIds); + if (!self || self->m_tornDown || generation != self->m_generation) co_return; + if (!result) { + spdlog::warn("[workshop] cannot resolve collection mods: {}", + result.error().message.toStdString()); + co_return; + } + self->m_packIds.clear(); + if (result->is_complete) { + self->m_packIds = + QSet(result->to_download.cbegin(), result->to_download.cend()); + self->m_packIds.unite(QSet(result->already_current.cbegin(), + result->already_current.cend())); + } + self->refreshRows(); + emit self->changed(); +} + +QCoro::Task WorkshopViewPanel::load(bool append) { + if (m_tornDown) co_return; + if (!configured()) { + ++m_generation; + m_busy = false; + emit changed(); + co_return; + } + clearDetail(); + auto* catalog = &m_catalog; + if (m_tab == QStringLiteral("Discover")) { + co_await runCached( + QStringLiteral("discover"), [catalog] { return catalog->discover(); }, + [this](const Discover& value) { + modsModel->setPage(rows(value.mods.items), + static_cast(value.mods.total), std::nullopt); + collectionsModel->setPage(rows(value.collections.items), + static_cast(value.collections.total), + std::nullopt); + }); + } else if (m_tab == QStringLiteral("Favourites")) { + ++m_generation; + m_busy = false; + m_error.clear(); + showFavourites(); + } else if (m_tab == QStringLiteral("Installed")) { + if (append) { + modsModel->setPage(rows(m_installed.nextPage()), m_installed.total(), + installedCursor(), true); + co_return; + } + m_installedDirty = false; + auto ids = m_installedIds.values(); + std::ranges::sort(ids); + const auto fingerprint = + QCryptographicHash::hash(ids.join(QLatin1Char(',')).toUtf8(), + QCryptographicHash::Sha256) + .toHex(); + co_await runCached>( + QStringLiteral("installed|") + QString::fromLatin1(fingerprint), + [catalog] { return catalog->installedWithUpdates(); }, + [this](const std::vector& value) { + m_installed.replace(value); + recount(); + showInstalled(); + }); + } else { + const bool collections = m_tab == QStringLiteral("Collections"); + auto* model = collections ? collectionsModel : modsModel; + auto query = m_criteria; + query.cursor = append ? model->cursor() : std::nullopt; + const QString key = + m_tab + QLatin1Char('|') + + QString::fromLatin1( + QCryptographicHash::hash( + QJsonDocument(QJsonArray{query.query, + query.tag.value_or(QString{}), + query.version.value_or(QString{}), + query.sort, query.limit, query.source}) + .toJson(QJsonDocument::Compact), + QCryptographicHash::Sha256) + .toHex()); + if (collections) { + co_await runCached>( + key, [catalog, query] { return catalog->collections(query); }, + [this, model, append](const CatalogPage& value) { + model->setPage(rows(value.items), static_cast(value.total), + cursorOf(value.next_cursor), append); + }, + !append); + } else { + co_await runCached>( + key, [catalog, query] { return catalog->mods(query); }, + [this, model, append](const CatalogPage& value) { + model->setPage(rows(value.items), static_cast(value.total), + cursorOf(value.next_cursor), append); + }, + !append); + } + } +} + +void WorkshopViewPanel::showFavourites() { + std::vector matches; + const auto query = m_criteria.query.toCaseFolded(); + for (const auto& item : m_favourites.items()) + if (QString::fromStdString(item.title).toCaseFolded().contains(query)) + matches.push_back(item); + collectionsModel->setPage(rows(matches), static_cast(matches.size()), + std::nullopt); + emit changed(); +} +std::optional WorkshopViewPanel::installedCursor() const { + return m_installed.hasMore() ? std::optional(QStringLiteral("more")) + : std::nullopt; +} +void WorkshopViewPanel::showInstalled() { + clearDetail(); + modsModel->setPage(rows(m_installed.visible()), m_installed.total(), + installedCursor()); + emit changed(); +} +void WorkshopViewPanel::recount() { + m_updateCount = 0; + for (const auto& mod : m_installed.mods()) + m_updateCount += m_catalog.installState(mod) == InstallState::kOutdated; +} + +QCoro::Task WorkshopViewPanel::selectTabAsync(QString tab) { + m_tab = std::move(tab); + m_notice.clear(); + m_pending.reset(); + clearDetail(); + m_installed.resetPaging(); + co_await load(); +} +QCoro::Task WorkshopViewPanel::filterItems(QString query, QString version, + QString source, QString sort, + QString tag) { + m_criteria = CatalogQuery{ + .query = query, + .tag = tag.isEmpty() ? std::nullopt : std::optional(tag), + .version = version.isEmpty() ? std::nullopt : std::optional(version), + .sort = sort, + .source = source}; + m_installed.search(query); + if (m_tab == QStringLiteral("Installed") && m_installed.loaded()) { + showInstalled(); + co_return; + } + if (m_tab == QStringLiteral("Discover")) m_tab = QStringLiteral("Mods"); + co_await load(); +} +QCoro::Task WorkshopViewPanel::retryLoad() { + if (m_retry && !m_tornDown) { + auto action = m_retry; + co_await action(); + } + co_return; +} +void WorkshopViewPanel::go(const QStringList& path) { + m_app.navigate(buildRoute(QStringLiteral("workshop"), path)); +} +void WorkshopViewPanel::selectTab(const QString& tab) { go({tab.toLower()}); } +void WorkshopViewPanel::openItem(const QString& itemId, const QString& kind) { + go({kind, itemId}); +} +void WorkshopViewPanel::openRoute(const QStringList& path) { + followRoute(path); +} +QCoro::Task WorkshopViewPanel::followRoute(QStringList path) { + if (m_tornDown || path.isEmpty()) co_return; + const QStringList tabs{QStringLiteral("Discover"), QStringLiteral("Mods"), + QStringLiteral("Collections"), + QStringLiteral("Favourites"), + QStringLiteral("Installed")}; + if (path.size() == 1) { + for (const auto& tab : tabs) + if (tab.compare(path.front(), Qt::CaseInsensitive) == 0) { + co_await selectTabAsync(tab); + co_return; + } + } else if (path.size() == 2 && + (path.front() == QStringLiteral("mod") || + path.front() == QStringLiteral("collection"))) { + co_await openItemAsync(path[1], path[0]); + co_return; + } + spdlog::warn("[workshop] unknown route {}", + buildRoute(QStringLiteral("workshop"), path).toStdString()); +} +QCoro::Task WorkshopViewPanel::openItemAsync(QString itemId, QString kind, + bool record) { + m_packIds.clear(); + QPointer self(this); + auto* catalog = &m_catalog; + co_await runCached( + QStringLiteral("detail|") + kind + QLatin1Char('|') + itemId, + [catalog, itemId, kind] { return fetchDetail(*catalog, itemId, kind); }, + [this, itemId, kind, record](const Detail& value) { + const auto ref = std::pair(kind, itemId); + if (m_current != ref) { + if (m_detail.isEmpty()) + m_trail.clear(); + else if (record && m_current) + m_trail.emplace_back( + m_current->first, m_current->second, + m_detail.value(QStringLiteral("title")).toString()); + } + m_current = ref; + auto members = rows(value.members); + m_detailData = value; + m_detail = detailRow(value); + membersModel->setPage(std::move(members), + static_cast(value.members.size()), + std::nullopt); + }); + if (self && !self->m_tornDown && kind == QStringLiteral("collection") && + self->m_current == std::pair(kind, itemId)) + co_await self->loadPack(); +} +QCoro::Task WorkshopViewPanel::goBack() { + if (m_tornDown) co_return; + if (!m_trail.empty()) { + auto [kind, item_id, title] = m_trail.back(); + QPointer self(this); + co_await openItemAsync(item_id, kind, false); + if (self && !self->m_tornDown && + self->m_current == std::pair(kind, item_id) && !self->m_trail.empty()) { + self->m_trail.pop_back(); + emit self->changed(); + } + co_return; + } + ++m_generation; + clearDetail(); + m_error.clear(); + m_busy = false; + emit changed(); + if (m_tab == QStringLiteral("Favourites")) + showFavourites(); + else if (m_tab == QStringLiteral("Installed") && m_installedDirty) + co_await load(); +} + +QCoro::Task WorkshopViewPanel::toggleActivationAsync(QString itemId) { + if (m_tornDown) co_return; + QStringList copies, active; + for (auto it = m_ctx.allMods().cbegin(); it != m_ctx.allMods().cend(); ++it) { + if (it.value()->publishedFileId() != itemId) continue; + copies.append(it.key()); + if (m_ctx.activeUuids().contains(it.key())) active.append(it.key()); + } + if (copies.isEmpty()) co_return; + if (active.isEmpty()) { + const QStringList only{copies.front()}; + co_await applyActivation(m_ctx, this, only, QStringList{}); + } else { + co_await applyActivation(m_ctx, this, QStringList{}, active); + } +} +QCoro::Task WorkshopViewPanel::toggleCollectionAsync(bool only) { + auto copies = packCopies(); + if (!copies) co_return; + QStringList disable; + const bool allActive = std::ranges::all_of(*copies, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); + for (const auto& uuid : m_ctx.activeUuids()) { + auto mod = m_ctx.allMods().value(uuid); + if (!mod) continue; + auto id = mod->publishedFileId(); + if (id && ((only && !copies->contains(uuid)) || + (!only && allActive && m_packIds.contains(*id)))) + disable.append(uuid); + } + if (!only && allActive) + co_await applyActivation(m_ctx, this, QStringList{}, disable); + else + co_await applyActivation(m_ctx, this, *copies, disable); +} +QCoro::Task WorkshopViewPanel::downloadItemAsync(QString itemId, + QString kind) { + QStringList mods; + QStringList collections; + if (kind == QStringLiteral("mod")) mods.append(itemId); + if (kind == QStringLiteral("collection")) collections.append(itemId); + co_await download(mods, collections); +} +QCoro::Task WorkshopViewPanel::updateAllAsync() { + QStringList ids; + for (const auto& mod : m_installed.mods()) + if (m_catalog.installState(mod) == InstallState::kOutdated) + ids.append(QString::fromStdString(mod.id)); + if (!ids.isEmpty()) co_await download(ids, QStringList{}); +} +void WorkshopViewPanel::queuePlan(const DownloadPlan& plan) { + m_catalog.enqueue(plan); + m_notice = queuedNotice(plan.to_download.size()); +} +QCoro::Task WorkshopViewPanel::download(QStringList ids, + QStringList collections) { + if (m_tornDown) co_return; + QPointer self(this); + m_notice = QStringLiteral("Preparing download…"); + m_pending.reset(); + ++m_planning; + auto requested = QSet(ids.cbegin(), ids.cend()); + requested.unite(QSet(collections.cbegin(), collections.cend())); + m_preparing.unite(requested); + refreshRows(); + emit changed(); + auto result = co_await m_catalog.plan(ids, collections); + if (!self || self->m_tornDown) co_return; + --self->m_planning; + self->m_preparing.subtract(requested); + self->refreshRows(); + emit self->changed(); + if (!result) { + self->m_notice = QStringLiteral("Could not prepare the download: ") + + result.error().message; + emit self->changed(); + co_return; + } + for (const auto& collection : collections) + self->m_collectionMods[collection] = + QSet(result->to_download.cbegin(), result->to_download.cend()); + if (!result->is_complete) { + self->m_pending = *result; + self->m_notice = incompletePlanNotice(*result); + } else if (result->to_download.isEmpty()) + self->m_notice = QStringLiteral( + "All available mods are already installed and up to date."); + else + self->queuePlan(*result); + emit self->changed(); +} +void WorkshopViewPanel::downloadAvailable() { + if (m_tornDown) return; + auto plan = std::exchange(m_pending, std::nullopt); + if (plan && !plan->to_download.isEmpty()) queuePlan(*plan); + emit changed(); +} +void WorkshopViewPanel::dismissPlan() { + m_pending.reset(); + m_notice.clear(); + emit changed(); +} +void WorkshopViewPanel::openLink(const QString& url) { + if (!safeUrl(url).isEmpty()) QDesktopServices::openUrl(QUrl(url)); +} +void WorkshopViewPanel::openSettings() { + m_app.navigate(buildRoute(QString(kSettingsViewId))); +} +void WorkshopViewPanel::toggleFavourite(const QString& itemId) { + const auto found = m_items.constFind(itemId); + if (found != m_items.cend()) + if (const auto* collection = std::get_if(&*found)) + m_favourites.set(*collection, !m_favourites.contains(collection->id)); +} +QCoro::Task WorkshopViewPanel::urlChanged() { + if (m_tornDown) co_return; + clearDetail(); + m_error.clear(); + m_notice.clear(); + m_pending.reset(); + m_items.clear(); + m_knownTags.clear(); + for (auto* model : {modsModel, collectionsModel, membersModel}) + model->setPage({}, 0, std::nullopt); + co_await load(); +} +QCoro::Task WorkshopViewPanel::installedChanged() { + if (m_tornDown) co_return; + m_installedDirty = true; + refreshModIds(); + recount(); + refreshRows(); + emit changed(); + if (m_tab == QStringLiteral("Installed") && !m_busy && !m_detailData) + co_await load(); +} +QString WorkshopViewPanel::url() const { + return buildRoute(QStringLiteral("workshop"), + m_current ? QStringList{m_current->first, m_current->second} + : QStringList{m_tab.toLower()}); +} +QString WorkshopViewPanel::backLabel() const { + return QStringLiteral("Back to ") + + (m_trail.empty() ? m_tab : std::get<2>(m_trail.back())); +} +QStringList WorkshopViewPanel::tagOptions() const { + auto tags = m_knownTags.values(); + std::ranges::sort(tags); + tags.prepend(QStringLiteral("All tags")); + return tags; +} +QStringList WorkshopViewPanel::versionOptions() const { + QStringList versions{QStringLiteral("All versions")}; + if (m_criteria.version) versions.append(*m_criteria.version); + for (const auto& version : {QStringLiteral("1.6"), QStringLiteral("1.5"), + QStringLiteral("1.4"), QStringLiteral("1.3")}) + if (!versions.contains(version)) versions.append(version); + return versions; +} + +} // namespace px::modrim::workshop diff --git a/src/ui/plugins/workshop/view.hpp b/src/ui/plugins/workshop/view.hpp new file mode 100644 index 0000000..b80e563 --- /dev/null +++ b/src/ui/plugins/workshop/view.hpp @@ -0,0 +1,231 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "ui/plugins/workshop/details.hpp" +#include "ui/plugins/workshop/installed.hpp" +#include "ui/plugins/workshop/models.hpp" +#include "ui/views/base.hpp" + +class QQuickWidget; + +namespace px::modrim::workshop { + +class WorkshopViewPanel : public BaseViewPanel { + Q_OBJECT + Q_PROPERTY(QString tab READ tab NOTIFY changed) + Q_PROPERTY(QString url READ url NOTIFY changed) + Q_PROPERTY(QString backLabel READ backLabel NOTIFY changed) + Q_PROPERTY(bool configured READ configured NOTIFY changed) + Q_PROPERTY(bool busy READ busy NOTIFY changed) + Q_PROPERTY(QString error READ error NOTIFY changed) + Q_PROPERTY(QString notice READ notice NOTIFY changed) + Q_PROPERTY(QVariantMap detail READ detail NOTIFY changed) + Q_PROPERTY(bool hasDetail READ hasDetail NOTIFY changed) + Q_PROPERTY(bool pendingPlan READ pendingPlan NOTIFY changed) + Q_PROPERTY(bool canDownloadAvailable READ canDownloadAvailable NOTIFY changed) + Q_PROPERTY(QString searchQuery READ searchQuery NOTIFY changed) + Q_PROPERTY(QStringList tagOptions READ tagOptions NOTIFY changed) + Q_PROPERTY(QString gameVersion READ gameVersion CONSTANT) + Q_PROPERTY(QStringList versionOptions READ versionOptions CONSTANT) + Q_PROPERTY(int updateCount READ updateCount NOTIFY changed) + Q_PROPERTY(bool planning READ planning NOTIFY changed) + + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("workshop"), + QLatin1StringView("steam"), + QLatin1StringView("Workshop")}; + WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + WorkshopViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app, workshop::WorkshopCatalog& catalog); + ~WorkshopViewPanel() override; + void openRoute(const QStringList& path) override; + Q_INVOKABLE void teardown() override; + QCoro::Task followRoute(QStringList path); + QCoro::Task load(bool append = false); + QCoro::Task selectTabAsync(QString tab); + QCoro::Task openItemAsync(QString itemId, QString kind, + bool record = true); + QCoro::Task filterItems(QString query, QString version, QString source, + QString sort, QString tag); + QCoro::Task goBack(); + QCoro::Task retryLoad(); + QCoro::Task downloadItemAsync(QString itemId, QString kind); + QCoro::Task toggleActivationAsync(QString itemId); + QCoro::Task toggleCollectionAsync(bool only = false); + QCoro::Task updateAllAsync(); + + Q_INVOKABLE QCoro::QmlTask refresh() { return load(); } + Q_INVOKABLE void selectTab(const QString& tab); + Q_INVOKABLE QCoro::QmlTask filter(QString query, QString version, + QString source, QString sort, QString tag) { + return filterItems(std::move(query), std::move(version), std::move(source), + std::move(sort), std::move(tag)); + } + Q_INVOKABLE QCoro::QmlTask loadMore() { return load(true); } + Q_INVOKABLE QCoro::QmlTask retry() { return retryLoad(); } + Q_INVOKABLE void openItem(const QString& itemId, const QString& kind); + Q_INVOKABLE QCoro::QmlTask back() { return goBack(); } + Q_INVOKABLE QCoro::QmlTask downloadItem(QString itemId, QString kind) { + return downloadItemAsync(std::move(itemId), std::move(kind)); + } + Q_INVOKABLE QCoro::QmlTask toggleActivation(QString itemId) { + return toggleActivationAsync(std::move(itemId)); + } + Q_INVOKABLE QCoro::QmlTask toggleCollection() { + return toggleCollectionAsync(); + } + Q_INVOKABLE QCoro::QmlTask activateOnlyCollection() { + return toggleCollectionAsync(true); + } + Q_INVOKABLE QCoro::QmlTask updateAll() { return updateAllAsync(); } + Q_INVOKABLE void downloadAvailable(); + Q_INVOKABLE void dismissPlan(); + Q_INVOKABLE void openLink(const QString& url); + Q_INVOKABLE void openSettings(); + Q_INVOKABLE void toggleFavourite(const QString& itemId); + + QString tab() const { return m_tab; } + QString url() const; + QString backLabel() const; + bool configured() const { return m_catalog.configured(); } + bool busy() const { return m_busy; } + QString error() const { return m_error; } + QString notice() const { return m_notice; } + QVariantMap detail() const { return m_detail; } + bool hasDetail() const { return !m_detail.isEmpty(); } + bool pendingPlan() const { return m_pending.has_value(); } + bool canDownloadAvailable() const { + return m_pending && !m_pending->to_download.isEmpty(); + } + QString searchQuery() const { return m_criteria.query; } + QStringList tagOptions() const; + QString gameVersion() const; + QStringList versionOptions() const; + int updateCount() const { return m_updateCount; } + bool planning() const { return m_planning > 0; } + + CatalogListModel* modsModel; + CatalogListModel* collectionsModel; + CatalogListModel* membersModel; + + signals: + void changed(); + + private: + template + QCoro::Task runCached( + QString key, + std::function>()> produce, + std::function apply, bool cached = true); + QCoro::Task loadPack(); + QCoro::Task download(QStringList ids, QStringList collections); + QCoro::Task installedChanged(); + QCoro::Task urlChanged(); + void refreshModIds(); + QVariantMap row(const workshop::CatalogEntry& item, bool detail = false); + QVariantMap detailRow(const Detail& detail); + template + QList rows(const std::vector& items) { + refreshModIds(); + QList result; + result.reserve(static_cast(items.size())); + for (const auto& item : items) result.append(row(item)); + return result; + } + void refreshRows(); + void clearDetail(); + std::optional packCopies() const; + void showFavourites(); + void showInstalled(); + void recount(); + std::optional installedCursor() const; + void queuePlan(const workshop::DownloadPlan& plan); + void go(const QStringList& path); + + workshop::WorkshopCatalog& m_catalog; + DownloadManager& m_downloads; + workshop::FavouriteCollections& m_favourites; + QQuickWidget* m_qml = nullptr; + QString m_tab = QStringLiteral("Discover"); + workshop::CatalogQuery m_criteria; + bool m_busy = false; + bool m_tornDown = false; + bool m_installedDirty = false; + int m_generation = 0; + int m_planning = 0; + int m_updateCount = 0; + QString m_error, m_notice; + std::function()> m_retry; + QVariantMap m_detail; + std::optional m_detailData; + InstalledList m_installed; + std::optional> m_current; + std::vector> m_trail; + std::optional m_pending; + QSet m_preparing, m_packIds, m_installedIds, m_activeIds, + m_knownTags; + QHash> m_collectionMods; + QHash m_items; + Event::Id m_busyId = 0; + Event::Id m_queueId = 0, m_installedId = 0; + Event::Id m_urlId = 0; + Event::Id m_favouritesId = 0; + Event::Id m_activeId = 0; +}; + +template +QCoro::Task WorkshopViewPanel::runCached( + QString key, + std::function>()> produce, + std::function apply, bool cached) { + if (m_tornDown) co_return; + QPointer self(this); + const int generation = ++m_generation; + m_busy = true; + m_error.clear(); + m_retry = [self, key, produce, apply, cached] { + return self->runCached(key, produce, apply, cached); + }; + emit changed(); + bool shown = false; + auto show = [self, generation, apply, &shown](const T& value) { + if (!self || self->m_tornDown || generation != self->m_generation) return; + apply(value); + shown = true; + self->m_busy = false; + emit self->changed(); + }; + workshop::CatalogResult result; + if (cached) + result = co_await m_catalog.cached(key, std::move(produce), show, + workshop::kFreshSeconds, true); + else { + auto fetched = co_await produce(); + if (fetched) + show(*fetched); + else + result = std::unexpected(fetched.error()); + } + if (!self || self->m_tornDown || generation != self->m_generation) co_return; + if (!result && shown) + spdlog::warn("[workshop] background refresh failed: {}", + result.error().message.toStdString()); + else if (!result) + self->m_error = result.error().message; + self->m_busy = false; + emit self->changed(); + if (self->m_installedDirty && self->m_tab == QStringLiteral("Installed") && + !self->m_detailData) + co_await self->load(); +} + +} // namespace px::modrim::workshop diff --git a/src/ui/settings_section.cpp b/src/ui/settings_section.cpp new file mode 100644 index 0000000..3c80e4d --- /dev/null +++ b/src/ui/settings_section.cpp @@ -0,0 +1,33 @@ +#include "ui/settings_section.hpp" + +#include +#include + +namespace px::modrim { + +PluginConfigSectionBase::PluginConfigSectionBase(QVariantMap initial, + QObject* parent) + : SettingsSection(parent), m_initial(std::move(initial)) {} + +QVariantMap PluginConfigSectionBase::initial() const { return m_initial; } + +bool PluginConfigSectionBase::set(const QString& key, const QVariant& value) { + if (!m_initial.contains(key)) { + return false; + } + m_draft.insert(key, value); + return true; +} + +Result PluginConfigSectionBase::apply() { + if (m_draft.isEmpty()) { + return {}; + } + QVariantMap merged = m_initial; + for (auto it = m_draft.cbegin(); it != m_draft.cend(); ++it) { + merged.insert(it.key(), it.value()); + } + return commit(QJsonObject::fromVariantMap(merged)); +} + +} // namespace px::modrim diff --git a/src/ui/settings_section.hpp b/src/ui/settings_section.hpp new file mode 100644 index 0000000..9a7d283 --- /dev/null +++ b/src/ui/settings_section.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/error.hpp" +#include "core/plugin_config.hpp" + +namespace px::modrim { + +// A plugin's group on the Settings dialog. `source()` is loaded into a group +// titled `title()` with this object as its required `section` property. Edits +// stay on the object until the dialog is saved, when `apply()` commits them; a +// cancelled dialog just drops it. +class SettingsSection : public QObject { + Q_OBJECT + + public: + using QObject::QObject; + + virtual QString title() const = 0; + virtual QUrl source() const = 0; + + virtual Result apply() = 0; +}; + +// Builds a plugin's section for one dialog, parented to that dialog. +using SettingsSectionFactory = std::function; + +// The QML-facing half of PluginConfigSection, independent of the settings type: +// QML reads `initial` and reports edits via `set`, and apply() hands the merged +// values to the typed commit(). +class PluginConfigSectionBase : public SettingsSection { + Q_OBJECT + + Q_PROPERTY(QVariantMap initial READ initial CONSTANT) + + public: + QVariantMap initial() const; + + // Returns false when `key` is not a field of the settings, so QML can tell + // the edit was dropped. + Q_INVOKABLE bool set(const QString& key, const QVariant& value); + + Result apply() final; + + protected: + PluginConfigSectionBase(QVariantMap initial, QObject* parent); + + // Commits the merged settings; an error leaves the stored config unchanged. + virtual Result commit(const QJsonObject& merged) = 0; + + private: + QVariantMap m_initial; + QVariantMap m_draft; +}; + +// Edits a PluginConfig. Keys and values follow T's JSON layout. +template +class PluginConfigSection : public PluginConfigSectionBase { + public: + PluginConfigSection(PluginConfig& config, QObject* parent) + : PluginConfigSectionBase(toVariantMap(config.value()), parent), + m_config(config) {} + + protected: + Result commit(const QJsonObject& merged) override { + const std::string text = + QJsonDocument(merged).toJson(QJsonDocument::Compact).toStdString(); + T value{}; + if (auto error = glz::read(value, text)) { + return std::unexpected( + Error{QString::fromStdString(glz::format_error(error, text))}); + } + m_config.update(std::move(value)); + return {}; + } + + private: + static QVariantMap toVariantMap(const T& value) { + auto text = glz::write_json(value); + if (!text) { + spdlog::warn("cannot encode plugin settings for the settings dialog"); + return {}; + } + return QJsonDocument::fromJson(QByteArray::fromStdString(*text)) + .object() + .toVariantMap(); + } + + PluginConfig& m_config; +}; + +} // namespace px::modrim diff --git a/src/ui/theme/constants.hpp b/src/ui/theme/constants.hpp new file mode 100644 index 0000000..e6d5122 --- /dev/null +++ b/src/ui/theme/constants.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace px::modrim { + +// Animation +inline constexpr int kAccordionAnimationDurationMs = 200; +inline constexpr QEasingCurve::Type kAccordionEasing = + QEasingCurve::Type::OutCubic; + +// Banner +inline constexpr int kBannerMaxHeight = 260; +inline constexpr int kBannerAspectW = 16; +inline constexpr int kBannerAspectH = 9; +inline constexpr int kBannerOverlayHeight = 120; + +// Layout +inline constexpr int kRailMinWidth = 52; +inline constexpr int kRailMaxWidth = 180; +inline constexpr int kRailCollapseWidth = 116; +inline constexpr int kSidebarWidth = 240; + +// Interaction +inline constexpr int kTooltipDelayMs = 400; + +} // namespace px::modrim diff --git a/src/ui/theme/palette.cpp b/src/ui/theme/palette.cpp new file mode 100644 index 0000000..f5edbb6 --- /dev/null +++ b/src/ui/theme/palette.cpp @@ -0,0 +1,115 @@ +#include "ui/theme/palette.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace px::modrim { + +namespace { + +struct PaletteEntry { + QLatin1StringView name; + QLatin1StringView value; +}; +constexpr PaletteEntry kPaletteEntries[]{ +#include "ui/theme/palette_table.inc" +}; + +// Mirrors the legacy string.Template.safe_substitute: a `$NAME` token is +// replaced only when NAME is a palette key; anything else stays as written. +QString expandTokens(const QString& raw) { + static const QRegularExpression token( + QStringLiteral(R"(\$([A-Z_][A-Z0-9_]*))")); + QString out; + out.reserve(raw.size()); + qsizetype last = 0; + auto it = token.globalMatch(raw); + while (it.hasNext()) { + const auto match = it.next(); + out.append(QStringView(raw).mid(last, match.capturedStart() - last)); + const QString value = paletteValue(match.capturedView(1)); + out.append(value.isEmpty() ? match.captured(0) : value); + last = match.capturedEnd(); + } + out.append(QStringView(raw).mid(last)); + return out; +} + +} // namespace + +QString paletteValue(QStringView name) { + for (const auto& entry : kPaletteEntries) { + if (QString(entry.name) == name) { + return QString(entry.value); + } + } + return {}; +} + +QColor paletteColor(QStringView name) { + const QString val = paletteValue(name); + return val.isEmpty() ? QColor{} : QColor(val); +} + +const QColor& panelBgQ() { + static const QColor color = paletteColor(QStringView{u"ELEVATE_2"}); + return color; +} + +const QColor& textMainQ() { + static const QColor color = paletteColor(QStringView{u"TEXT_MAIN"}); + return color; +} + +const QColor& textMutedQ() { + static const QColor color = paletteColor(QStringView{u"TEXT_MUTED"}); + return color; +} + +QString getStylesheet() { + QFile file(QStringLiteral(":/qt/qml/PxModRim/theme/style.qss")); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + spdlog::warn("failed to open the application stylesheet resource"); + return {}; + } + return expandTokens(QString::fromUtf8(file.readAll())); +} + +void applyAppTheme(QApplication& app) { + app.setStyle(QStringLiteral("Fusion")); + + QPalette palette; + palette.setColor(QPalette::Window, paletteColor(QStringView{u"ELEVATE_0"})); + palette.setColor(QPalette::WindowText, + paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Base, paletteColor(QStringView{u"ELEVATE_1"})); + palette.setColor(QPalette::AlternateBase, + paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::Text, paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Button, paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::ButtonText, + paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::BrightText, + paletteColor(QStringView{u"TEXT_MAIN"})); + palette.setColor(QPalette::Highlight, paletteColor(QStringView{u"PRIMARY"})); + palette.setColor(QPalette::HighlightedText, + paletteColor(QStringView{u"ELEVATE_0"})); + palette.setColor(QPalette::Link, paletteColor(QStringView{u"PRIMARY"})); + palette.setColor(QPalette::ToolTipBase, + paletteColor(QStringView{u"ELEVATE_2"})); + palette.setColor(QPalette::ToolTipText, + paletteColor(QStringView{u"TEXT_MAIN"})); + app.setPalette(palette); + + const QString qss = getStylesheet(); + if (!qss.isEmpty()) { + app.setStyleSheet(qss); + } +} + +} // namespace px::modrim diff --git a/src/ui/theme/palette.hpp b/src/ui/theme/palette.hpp new file mode 100644 index 0000000..b9e10d4 --- /dev/null +++ b/src/ui/theme/palette.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +class QApplication; + +namespace px::modrim { + +// Look up a color token by its uppercase name, e.g. "ELEVATE_0", "PRIMARY". +// Returns an empty string if the name is unknown. +QString paletteValue(QStringView name); + +// Same as paletteValue, parsed into a QColor. +QColor paletteColor(QStringView name); + +// Pre-constructed QColors matching legacy panel_bg_q, text_main_q, +// text_muted_q. +const QColor& panelBgQ(); +const QColor& textMainQ(); +const QColor& textMutedQ(); + +// Returns the application stylesheet (style.qss with $KEY tokens expanded). +QString getStylesheet(); + +// Sets Fusion style, dark palette, and the parsed stylesheet on `app`, +// matching legacy pxmodrim/_app.py `_apply_theme`. +void applyAppTheme(QApplication& app); + +} // namespace px::modrim diff --git a/src/ui/theme/palette_table.inc b/src/ui/theme/palette_table.inc new file mode 100644 index 0000000..df0bc6e --- /dev/null +++ b/src/ui/theme/palette_table.inc @@ -0,0 +1,28 @@ +// Generated once from legacy/pxmodrim/ui/theme/palette.py (PALETTE, same order). +{QLatin1StringView{"ELEVATE_0"}, QLatin1StringView{"#0b0d10"}}, +{QLatin1StringView{"ELEVATE_1"}, QLatin1StringView{"#121418"}}, +{QLatin1StringView{"ELEVATE_2"}, QLatin1StringView{"#1a1d21"}}, +{QLatin1StringView{"ELEVATE_3"}, QLatin1StringView{"#22262b"}}, +{QLatin1StringView{"ELEVATE_4"}, QLatin1StringView{"#2c3036"}}, +{QLatin1StringView{"SURFACE"}, QLatin1StringView{"#25282e"}}, +{QLatin1StringView{"BORDER"}, QLatin1StringView{"#2f333a"}}, +{QLatin1StringView{"TEXT_MAIN"}, QLatin1StringView{"#f2f3f5"}}, +{QLatin1StringView{"TEXT_MUTED"}, QLatin1StringView{"#949ba4"}}, +{QLatin1StringView{"TEXT_DIM"}, QLatin1StringView{"#6c737f"}}, +{QLatin1StringView{"TEXT_ON_ACCENT"}, QLatin1StringView{"#0b0d10"}}, +{QLatin1StringView{"NEUTRAL"}, QLatin1StringView{"#6b7280"}}, +{QLatin1StringView{"PRIMARY"}, QLatin1StringView{"#66c0f4"}}, +{QLatin1StringView{"PRIMARY_HOVER"}, QLatin1StringView{"#4aa8d8"}}, +{QLatin1StringView{"PRIMARY_BG"}, QLatin1StringView{"rgba(102,192,244,0.1)"}}, +{QLatin1StringView{"PRIMARY_BG_QML"}, QLatin1StringView{"#1a66c0f4"}}, +{QLatin1StringView{"SUCCESS"}, QLatin1StringView{"#3ba55d"}}, +{QLatin1StringView{"SUCCESS_HOVER"}, QLatin1StringView{"#2f8a4c"}}, +{QLatin1StringView{"SUCCESS_BG"}, QLatin1StringView{"rgba(59,165,93,0.15)"}}, +{QLatin1StringView{"SUCCESS_BG_QML"}, QLatin1StringView{"#263ba55d"}}, +{QLatin1StringView{"WARNING"}, QLatin1StringView{"#f9a825"}}, +{QLatin1StringView{"WARNING_BG"}, QLatin1StringView{"rgba(249,168,37,0.15)"}}, +{QLatin1StringView{"WARNING_BG_QML"}, QLatin1StringView{"#26f9a825"}}, +{QLatin1StringView{"DANGER"}, QLatin1StringView{"#ed4245"}}, +{QLatin1StringView{"DANGER_HOVER"}, QLatin1StringView{"#d1383b"}}, +{QLatin1StringView{"DANGER_BG"}, QLatin1StringView{"rgba(237,66,69,0.15)"}}, +{QLatin1StringView{"DANGER_BG_QML"}, QLatin1StringView{"#26ed4245"}}, diff --git a/src/ui/theme/qml_theme.cpp b/src/ui/theme/qml_theme.cpp new file mode 100644 index 0000000..d0af2dd --- /dev/null +++ b/src/ui/theme/qml_theme.cpp @@ -0,0 +1,98 @@ +#include "ui/theme/qml_theme.hpp" + +#include +#include +#include + +#include + +#include "ui/theme/constants.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +namespace { + +const QString kLogoDir = QStringLiteral(":/qt/qml/PxModRim/assets/"); + +// Reads the four embedded logo variants and encodes each as an SVG data URI. +QStringList loadLogoVariants() { + QStringList uris; + for (int n = 1; n <= 4; ++n) { + const QString path = kLogoDir + QStringLiteral("logo_nobg_%1.svg").arg(n); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + spdlog::error("missing embedded logo resource {}", path.toStdString()); + continue; + } + uris.append(QStringLiteral("data:image/svg+xml;base64,") + + QString::fromLatin1(file.readAll().toBase64())); + } + return uris; +} + +} // namespace + +Theme::Theme(QObject* parent) + : QObject(parent), m_logoVariants(loadLogoVariants()) { + if (!m_logoVariants.isEmpty()) { + const int pick = QRandomGenerator::global()->bounded( + static_cast(m_logoVariants.size())); + m_logoDataUri = m_logoVariants.at(pick); + } +} + +QString Theme::elevate0() const { return paletteValue(u"ELEVATE_0"); } +QString Theme::elevate1() const { return paletteValue(u"ELEVATE_1"); } +QString Theme::elevate2() const { return paletteValue(u"ELEVATE_2"); } +QString Theme::elevate3() const { return paletteValue(u"ELEVATE_3"); } +QString Theme::elevate4() const { return paletteValue(u"ELEVATE_4"); } +QString Theme::border() const { return paletteValue(u"BORDER"); } + +QString Theme::textMain() const { return paletteValue(u"TEXT_MAIN"); } +QString Theme::textMuted() const { return paletteValue(u"TEXT_MUTED"); } +QString Theme::textDim() const { return paletteValue(u"TEXT_DIM"); } +QString Theme::onAccent() const { return paletteValue(u"TEXT_ON_ACCENT"); } +QString Theme::neutral() const { return paletteValue(u"NEUTRAL"); } + +QString Theme::primary() const { return paletteValue(u"PRIMARY"); } +QString Theme::primaryHover() const { return paletteValue(u"PRIMARY_HOVER"); } +QString Theme::primaryBg() const { return paletteValue(u"PRIMARY_BG_QML"); } +QString Theme::success() const { return paletteValue(u"SUCCESS"); } +QString Theme::successHover() const { return paletteValue(u"SUCCESS_HOVER"); } +QString Theme::successBg() const { return paletteValue(u"SUCCESS_BG_QML"); } +QString Theme::warning() const { return paletteValue(u"WARNING"); } +QString Theme::warningBg() const { return paletteValue(u"WARNING_BG_QML"); } +QString Theme::danger() const { return paletteValue(u"DANGER"); } +QString Theme::dangerHover() const { return paletteValue(u"DANGER_HOVER"); } +QString Theme::dangerBg() const { return paletteValue(u"DANGER_BG_QML"); } + +QString Theme::fontFamily() const { + return QStringLiteral("Source Sans 3, Liberation Sans, sans-serif"); +} +QString Theme::fontMono() const { + return QStringLiteral("DejaVu Sans Mono, Liberation Mono, monospace"); +} +int Theme::fontSizeXs() const { return 10; } +int Theme::fontSizeSm() const { return 11; } +int Theme::fontSizeMd() const { return 13; } +int Theme::fontSizeLg() const { return 14; } +int Theme::fontSizeXl() const { return 18; } +int Theme::radiusXs() const { return 3; } +int Theme::radiusSm() const { return 4; } +int Theme::radiusMd() const { return 6; } +int Theme::radiusLg() const { return 8; } +int Theme::radiusPill() const { return 999; } +int Theme::scrollbarWidth() const { return 6; } +int Theme::tooltipDelay() const { return kTooltipDelayMs; } +double Theme::disabledOpacity() const { return 0.45; } +QString Theme::overlay() const { return QStringLiteral("#8c000000"); } +int Theme::railMinWidth() const { return kRailMinWidth; } +int Theme::railMaxWidth() const { return kRailMaxWidth; } +int Theme::railCollapseWidth() const { return kRailCollapseWidth; } +int Theme::sidebarWidth() const { return kSidebarWidth; } + +QStringList Theme::logoVariants() const { return m_logoVariants; } +QString Theme::logoFileDataUri() const { return m_logoDataUri; } + +} // namespace px::modrim diff --git a/src/ui/theme/qml_theme.hpp b/src/ui/theme/qml_theme.hpp new file mode 100644 index 0000000..3a668c0 --- /dev/null +++ b/src/ui/theme/qml_theme.hpp @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include + +namespace px::modrim { + +// Design tokens for QML, exposed as the `Theme` root context property (see +// createQmlEngine). Color values come from the same PALETTE as the stylesheet; +// *Bg values are #AARRGGBB because QML rejects rgba() strings. +class Theme : public QObject { + Q_OBJECT + + Q_PROPERTY(QString elevate0 READ elevate0 CONSTANT) + Q_PROPERTY(QString elevate1 READ elevate1 CONSTANT) + Q_PROPERTY(QString elevate2 READ elevate2 CONSTANT) + Q_PROPERTY(QString elevate3 READ elevate3 CONSTANT) + Q_PROPERTY(QString elevate4 READ elevate4 CONSTANT) + Q_PROPERTY(QString border READ border CONSTANT) + + Q_PROPERTY(QString textMain READ textMain CONSTANT) + Q_PROPERTY(QString textMuted READ textMuted CONSTANT) + Q_PROPERTY(QString textDim READ textDim CONSTANT) + Q_PROPERTY(QString onAccent READ onAccent CONSTANT) + Q_PROPERTY(QString neutral READ neutral CONSTANT) + + Q_PROPERTY(QString primary READ primary CONSTANT) + Q_PROPERTY(QString primaryHover READ primaryHover CONSTANT) + Q_PROPERTY(QString primaryBg READ primaryBg CONSTANT) + Q_PROPERTY(QString success READ success CONSTANT) + Q_PROPERTY(QString successHover READ successHover CONSTANT) + Q_PROPERTY(QString successBg READ successBg CONSTANT) + Q_PROPERTY(QString warning READ warning CONSTANT) + Q_PROPERTY(QString warningBg READ warningBg CONSTANT) + Q_PROPERTY(QString danger READ danger CONSTANT) + Q_PROPERTY(QString dangerHover READ dangerHover CONSTANT) + Q_PROPERTY(QString dangerBg READ dangerBg CONSTANT) + + Q_PROPERTY(QString fontFamily READ fontFamily CONSTANT) + Q_PROPERTY(QString fontMono READ fontMono CONSTANT) + Q_PROPERTY(int fontSizeXs READ fontSizeXs CONSTANT) + Q_PROPERTY(int fontSizeSm READ fontSizeSm CONSTANT) + Q_PROPERTY(int fontSizeMd READ fontSizeMd CONSTANT) + Q_PROPERTY(int fontSizeLg READ fontSizeLg CONSTANT) + Q_PROPERTY(int fontSizeXl READ fontSizeXl CONSTANT) + Q_PROPERTY(int radiusXs READ radiusXs CONSTANT) + Q_PROPERTY(int radiusSm READ radiusSm CONSTANT) + Q_PROPERTY(int radiusMd READ radiusMd CONSTANT) + Q_PROPERTY(int radiusLg READ radiusLg CONSTANT) + Q_PROPERTY(int radiusPill READ radiusPill CONSTANT) + Q_PROPERTY(int scrollbarWidth READ scrollbarWidth CONSTANT) + Q_PROPERTY(int tooltipDelay READ tooltipDelay CONSTANT) + Q_PROPERTY(double disabledOpacity READ disabledOpacity CONSTANT) + Q_PROPERTY(QString overlay READ overlay CONSTANT) + Q_PROPERTY(int railMinWidth READ railMinWidth CONSTANT) + Q_PROPERTY(int railMaxWidth READ railMaxWidth CONSTANT) + Q_PROPERTY(int railCollapseWidth READ railCollapseWidth CONSTANT) + Q_PROPERTY(int sidebarWidth READ sidebarWidth CONSTANT) + + Q_PROPERTY(QStringList logoVariants READ logoVariants CONSTANT) + Q_PROPERTY(QString logoFileDataUri READ logoFileDataUri CONSTANT) + + public: + // Picks one of the four logo variants at random, as the legacy Theme did. + explicit Theme(QObject* parent = nullptr); + + QString elevate0() const; + QString elevate1() const; + QString elevate2() const; + QString elevate3() const; + QString elevate4() const; + QString border() const; + + QString textMain() const; + QString textMuted() const; + QString textDim() const; + QString onAccent() const; + QString neutral() const; + + QString primary() const; + QString primaryHover() const; + QString primaryBg() const; + QString success() const; + QString successHover() const; + QString successBg() const; + QString warning() const; + QString warningBg() const; + QString danger() const; + QString dangerHover() const; + QString dangerBg() const; + + QString fontFamily() const; + QString fontMono() const; + int fontSizeXs() const; + int fontSizeSm() const; + int fontSizeMd() const; + int fontSizeLg() const; + int fontSizeXl() const; + int radiusXs() const; + int radiusSm() const; + int radiusMd() const; + int radiusLg() const; + int radiusPill() const; + int scrollbarWidth() const; + int tooltipDelay() const; + double disabledOpacity() const; + QString overlay() const; + int railMinWidth() const; + int railMaxWidth() const; + int railCollapseWidth() const; + int sidebarWidth() const; + + QStringList logoVariants() const; + QString logoFileDataUri() const; + + private: + QStringList m_logoVariants; + QString m_logoDataUri; +}; + +} // namespace px::modrim diff --git a/src/pxmodrim/ui/theme/style.qss b/src/ui/theme/style.qss similarity index 100% rename from src/pxmodrim/ui/theme/style.qss rename to src/ui/theme/style.qss diff --git a/src/pxmodrim/ui/plugins/workshop/thumbs.js b/src/ui/thumbs.js similarity index 100% rename from src/pxmodrim/ui/plugins/workshop/thumbs.js rename to src/ui/thumbs.js diff --git a/src/ui/ui_prefs.hpp b/src/ui/ui_prefs.hpp new file mode 100644 index 0000000..ca7db86 --- /dev/null +++ b/src/ui/ui_prefs.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "core/constants.hpp" + +namespace px::modrim { + +// Persisted UI state (ui_prefs.json). Field names are the JSON keys. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct UiPrefs { + bool desc_expanded = false; + LaunchStrategy launch_strategy = LaunchStrategy::kDirect; + std::string skipped_update_tag; + bool rail_collapsed = false; + + bool operator==(const UiPrefs&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +} // namespace px::modrim diff --git a/src/ui/views/base.cpp b/src/ui/views/base.cpp new file mode 100644 index 0000000..360053e --- /dev/null +++ b/src/ui/views/base.cpp @@ -0,0 +1,26 @@ +#include "ui/views/base.hpp" + +#include +#include + +#include "ui/theme/palette.hpp" + +namespace px::modrim { + +BaseViewPanel::BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : RailView(ctx, engine, parent, app) { + setAutoFillBackground(true); + m_root = new QVBoxLayout(this); + m_root->setContentsMargins(0, 0, 0, 0); + m_root->setSpacing(0); + setClearColor(paletteValue(u"ELEVATE_0")); +} + +void BaseViewPanel::setClearColor(const QString& color) { + QPalette palette = this->palette(); + palette.setColor(QPalette::Window, QColor(color)); + setPalette(palette); +} + +} // namespace px::modrim diff --git a/src/ui/views/base.hpp b/src/ui/views/base.hpp new file mode 100644 index 0000000..365fd09 --- /dev/null +++ b/src/ui/views/base.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "ui/context.hpp" + +namespace px::modrim { + +// Whole-window view contributed to the left icon rail. Core never includes +// this: views are discovered entirely within ui/. Subclasses declare +// `kRailMeta`. +class BaseViewPanel : public RailView { + Q_OBJECT + + public: + BaseViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + + protected: + void setClearColor(const QString& color); + + QVBoxLayout* m_root = nullptr; +}; + +} // namespace px::modrim diff --git a/src/ui/views/mods_view.cpp b/src/ui/views/mods_view.cpp new file mode 100644 index 0000000..ef8acdc --- /dev/null +++ b/src/ui/views/mods_view.cpp @@ -0,0 +1,43 @@ +#include "ui/views/mods_view.hpp" + +#include + +#include "ui/theme/constants.hpp" +namespace px::modrim { +ModsViewPanel::ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, + QWidget* parent, AppContext& app) + : BaseViewPanel(ctx, engine, parent, app) { + auto* content = new QWidget(this); + content->setObjectName(QStringLiteral("contentArea")); + auto* layout = new QHBoxLayout(content); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + sidebar = new SidebarPanel(ctx, engine, content); + sidebar->setObjectName(QStringLiteral("sidebarPanel")); + sidebar->setFixedWidth(kSidebarWidth); + connect(sidebar, &SidebarPanel::entrySelected, this, + &ModsViewPanel::entrySelected); + layout->addWidget(sidebar); + modList = new ModListPanel(ctx, engine, content); + modList->setObjectName(QStringLiteral("modListPanel")); + connect(modList, &ModListPanel::modPicked, this, &ModsViewPanel::modSelected); + connect(modList, &ModListPanel::activeModsChanged, this, + &ModsViewPanel::activeModsChanged, Qt::QueuedConnection); + connect(modList, &ModListPanel::orderChanged, this, + &ModsViewPanel::orderChanged, Qt::QueuedConnection); + layout->addWidget(modList, 3); + modInfo = new ModInfoPanel(ctx, engine, app.uiPrefs(), content); + modInfo->setObjectName(QStringLiteral("modInfoPanel")); + modInfo->setMinimumWidth(300); + layout->addWidget(modInfo, 2); + m_root->addWidget(content, 1); +} +void ModsViewPanel::applySidebarEntry(const view::SidebarEntry& entry) { + modList->clearSelection(); + modList->setSidebarFilter(entry.visibleUuids); +} +void ModsViewPanel::applyCurrentSidebarFilter() { + if (const auto entry = sidebar->currentEntry()) + modList->setSidebarFilter(entry->visibleUuids); +} +} // namespace px::modrim diff --git a/src/ui/views/mods_view.hpp b/src/ui/views/mods_view.hpp new file mode 100644 index 0000000..6d7074e --- /dev/null +++ b/src/ui/views/mods_view.hpp @@ -0,0 +1,26 @@ +#pragma once +#include "ui/panels/mod_info_panel.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/panels/sidebar_panel.hpp" +#include "ui/views/base.hpp" +namespace px::modrim { +class ModsViewPanel : public BaseViewPanel { + Q_OBJECT + public: + static constexpr RailViewMeta kRailMeta{QLatin1StringView("mods"), + QLatin1StringView("mods"), + QLatin1StringView("Mods")}; + ModsViewPanel(CoreContext& ctx, QQmlEngine* engine, QWidget* parent, + AppContext& app); + void applySidebarEntry(const view::SidebarEntry& entry); + void applyCurrentSidebarFilter(); + SidebarPanel* sidebar; + ModListPanel* modList; + ModInfoPanel* modInfo; + signals: + void entrySelected(const px::modrim::view::SidebarEntry& entry); + void modSelected(const QString& uuid); + void activeModsChanged(); + void orderChanged(); +}; +} // namespace px::modrim diff --git a/src/ui/views/views.hpp b/src/ui/views/views.hpp new file mode 100644 index 0000000..f642330 --- /dev/null +++ b/src/ui/views/views.hpp @@ -0,0 +1,5 @@ +#pragma once + +// Aggregate of the built-in whole-window views. Each view header is added here +// as its port lands; registration happens in src/app/plugins.cpp. +#include "ui/views/base.hpp" diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp new file mode 100644 index 0000000..46d6fca --- /dev/null +++ b/src/ui/window/actions.cpp @@ -0,0 +1,108 @@ +#include "ui/window/actions.hpp" + +#include +#include +#include + +namespace px::modrim { + +QString ActionSpec::tooltip() const { + return shortcut.isEmpty() + ? label + : QStringLiteral("%1 (%2)").arg( + label, + QKeySequence(shortcut).toString(QKeySequence::NativeText)); +} + +const std::array& actionSpecs() { + static const std::array specs{{ + {ActionId::kSave, "save", "&Save Mod List", "Save mod list", "Ctrl+S"}, + {ActionId::kRestore, + "restore", + "&Restore Mod List…", + "Restore mod list", + {}}, + {ActionId::kSettings, "settings", "&Settings…", "Settings", "Ctrl+,"}, + {ActionId::kQuit, "quit", "&Quit", "Quit", "Ctrl+Q"}, + {ActionId::kRefresh, "refresh", "&Refresh Mods", "Refresh mods", "F5"}, + {ActionId::kFullRescan, "full_rescan", "&Full Mod Rescan", + "Full mod rescan", "Ctrl+Shift+R"}, + {ActionId::kAutoSort, + "auto_sort", + "&Auto-Sort Mods", + "Auto-sort mods", + {}}, + {ActionId::kUpdateMods, + "update_mods", + "&Update Mods", + "Update downloaded mods", + {}}, + {ActionId::kFocusSearch, "focus_search", "Focus &Search", "Focus search", + "Ctrl+F"}, + {ActionId::kNextView, "next_view", "&Next View", "Next view", "Ctrl+Tab"}, + {ActionId::kPrevView, "prev_view", "&Previous View", "Previous view", + "Ctrl+Shift+Tab"}, + {ActionId::kFullscreen, "fullscreen", "Toggle &Fullscreen", + "Toggle fullscreen", "F11"}, + {ActionId::kReportIssue, + "report_issue", + "&Report Issue", + "Report issue", + {}}, + {ActionId::kUploadLogs, + "upload_logs", + "Log && System &Info…", + "Log & system info", + {}}, + {ActionId::kOpenLogs, + "open_logs", + "Open &Logs Folder", + "Open logs folder", + {}}, + {ActionId::kShortcuts, + "shortcuts", + "&Keyboard Shortcuts", + "Keyboard shortcuts", + {}}, + {ActionId::kCheckUpdates, + "check_updates", + "Check for &Updates…", + "Check for updates", + {}}, + {ActionId::kAbout, "about", "&About PxModRim", "About PxModRim", "F1"}, + }}; + return specs; +} + +const ActionSpec& actionSpec(ActionId id) { + return actionSpecs()[static_cast(id)]; +} + +ActionMap createActions(QWidget& window) { + ActionMap actions; + for (const auto& spec : actionSpecs()) { + auto* action = new QAction(spec.menuText, &window); + if (!spec.shortcut.isEmpty()) + action->setShortcut(QKeySequence(spec.shortcut)); + window.addAction(action); + actions.emplace(spec.id, action); + } + return actions; +} + +QList> shortcutRows() { + QList> rows; + for (const auto& spec : actionSpecs()) { + if (!spec.shortcut.isEmpty()) { + rows.append( + {QKeySequence(spec.shortcut).toString(QKeySequence::NativeText), + spec.label}); + } + } + rows.append({QStringLiteral("Ctrl+1–9"), QStringLiteral("Switch to view")}); + rows.append({QStringLiteral("Alt"), QStringLiteral("Show or hide menu bar")}); + rows.append({QStringLiteral("Esc"), QStringLiteral("Hide menu bar")}); + return rows; +} + +} // namespace px::modrim diff --git a/src/ui/window/actions.hpp b/src/ui/window/actions.hpp new file mode 100644 index 0000000..4628313 --- /dev/null +++ b/src/ui/window/actions.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class QAction; +class QWidget; + +namespace px::modrim { + +enum class ActionId { + kSave, + kRestore, + kSettings, + kQuit, + kRefresh, + kFullRescan, + kAutoSort, + kUpdateMods, + kFocusSearch, + kNextView, + kPrevView, + kFullscreen, + kReportIssue, + kUploadLogs, + kOpenLogs, + kShortcuts, + kCheckUpdates, + kAbout, +}; + +struct ActionSpec { + ActionId id; + QString key; + QString menuText; + QString label; + QString shortcut; + QString tooltip() const; +}; + +using ActionMap = std::map; +const std::array& actionSpecs(); +const ActionSpec& actionSpec(ActionId id); +ActionMap createActions(QWidget& window); +QList> shortcutRows(); + +} // namespace px::modrim diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp new file mode 100644 index 0000000..10c539a --- /dev/null +++ b/src/ui/window/main_window.cpp @@ -0,0 +1,802 @@ +#include "ui/window/main_window.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/services/game_launcher.hpp" +#include "core/version.hpp" +#include "ui/components/dialogs.hpp" +#include "ui/components/header_controller.hpp" +#include "ui/components/header_panel.hpp" +#include "ui/components/mod_updates.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/components/toast.hpp" +#include "ui/components/view_rail_panel.hpp" +#include "ui/config.hpp" +#include "ui/mod_selection.hpp" +#include "ui/navigation.hpp" +#include "ui/panels/about_panel.hpp" +#include "ui/panels/keyboard_shortcuts_dialog.hpp" +#include "ui/panels/mod_info_panel.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/panels/restore_snapshot_dialog.hpp" +#include "ui/panels/settings_panel.hpp" +#include "ui/panels/update_dialog.hpp" +#include "ui/panels/upload_report_dialog.hpp" +#include "ui/theme/constants.hpp" +#include "ui/theme/qml_theme.hpp" +#include "ui/views/mods_view.hpp" +#include "ui/window/menu_bar.hpp" + +namespace px::modrim { + +UnsavedChangesDialog::UnsavedChangesDialog(QWidget* parent) + : QMessageBox(parent) { + setIcon(Question); + setWindowTitle(QStringLiteral("Unsaved Changes")); + setText(QStringLiteral("The active mod list has unsaved changes.")); + setInformativeText(QStringLiteral( + "Save before closing, discard the changes, or cancel closing.")); + setStandardButtons(Save | Discard | Cancel); + setDefaultButton(Save); +} + +LaunchConfirmDialog::LaunchConfirmDialog(QWidget* parent, const QString& title, + const QString& text, + const QString& info, + const QString& accept) + : QMessageBox(parent) { + setIcon(Warning); + setWindowTitle(title); + setText(text); + setInformativeText(info); + setStandardButtons(Ok | Cancel); + button(Ok)->setText(accept); + setDefaultButton(Cancel); + setCheckBox(new QCheckBox(QStringLiteral("Don't ask again"), this)); +} + +bool LaunchConfirmDialog::dontAskAgain() const { + return checkBox()->isChecked(); +} + +MainWindow::MainWindow(AppContext& app, QWidget* parent) + : QMainWindow(parent), + m_app(app), + m_ctx(app.coreContext()), + m_startedWithoutGamePath(m_ctx.config().paths.game.empty()), + m_engine(createQmlEngine(this)), + m_savedActive(m_ctx.loadedActiveUuids()), + m_updates(appVersion(), m_ctx.network()) { + setWindowTitle(QStringLiteral("PxModRim[*]")); + setWindowIcon(QIcon(QStringLiteral(":/qt/qml/PxModRim/assets/logo.svg"))); + resize(1400, 850); + setWindowFlags(Qt::Window | Qt::FramelessWindowHint | + Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint | + Qt::WindowCloseButtonHint); + setFocusPolicy(Qt::ClickFocus); + m_engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new Theme(m_engine)); + + const QVariantMap tooltips{ + {"refresh", actionSpec(ActionId::kRefresh).tooltip()}, + {"sort", actionSpec(ActionId::kAutoSort).tooltip()}, + {"save", actionSpec(ActionId::kSave).tooltip()}, + {"update_mods", actionSpec(ActionId::kUpdateMods).tooltip()}, + }; + m_headerController = new HeaderController( + windowFlags().testFlag(Qt::FramelessWindowHint), + static_cast(m_app.uiPrefs().launch_strategy), appVersion(), tooltips, + m_ctx.downloads().available(), this); + m_header = new HeaderPanel(*m_headerController, m_engine, this); + setupActions(); + m_menu = new MenuBar(m_actions, this); + m_menu->hide(); + connect(m_headerController, &HeaderController::refreshRequested, this, + [this] { reloadMods(); }); + connect(m_headerController, &HeaderController::sortRequested, this, + [this] { autoSort(); }); + connect(m_headerController, &HeaderController::saveRequested, this, + [this] { saveActiveMods(); }); + connect(m_headerController, &HeaderController::launchRequested, this, + [this] { launchGame(); }); + connect(m_headerController, &HeaderController::updateModsRequested, this, + [this] { updateMods(); }); + connect(m_headerController, &HeaderController::downloadsRequested, this, + &MainWindow::openDownloads); + connect(m_headerController, &HeaderController::minimizeRequested, this, + &QWidget::showMinimized); + connect(m_headerController, &HeaderController::maximizeRequested, this, + [this] { isMaximized() ? showNormal() : showMaximized(); }); + connect(m_headerController, &HeaderController::closeRequested, this, + &QWidget::close); + connect(m_headerController, &HeaderController::dragStartRequested, this, + [this] { + if (windowHandle()) windowHandle()->startSystemMove(); + }); + connect(m_headerController, &HeaderController::strategyChanged, this, + [this](int index) { + const auto strategy = static_cast(index); + if (m_app.uiPrefs().launch_strategy != strategy) { + m_app.uiPrefs().launch_strategy = strategy; + savePrefs(); + } + }); + + auto* outer = new QWidget(this); + outer->setObjectName(QStringLiteral("outerContainer")); + auto* layout = new QVBoxLayout(outer); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + layout->addWidget(m_header); + layout->addWidget(m_menu); + QVariantList tabs; + for (const auto& view : m_app.railViews()) { + tabs.append(QVariantMap{{"viewId", QString(view.meta.viewId)}, + {"icon", QString(view.meta.iconName)}, + {"label", QString(view.meta.label)}}); + } + QVariantList help; + for (const auto id : + {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, + ActionId::kShortcuts, ActionId::kCheckUpdates, ActionId::kAbout}) { + const auto& spec = actionSpec(id); + help.append(QVariantMap{{"id", spec.key}, {"label", spec.label}}); + } + m_splitter = new QSplitter(Qt::Horizontal, outer); + m_splitter->setContentsMargins(0, 0, 0, 0); + m_splitter->setHandleWidth(1); + m_rail = new ViewRailPanel(tabs, help, m_engine, m_splitter); + m_rail->setObjectName(QStringLiteral("viewRail")); + m_rail->setMinimumWidth(kRailMinWidth); + m_rail->setMaximumWidth(kRailMaxWidth); + m_stack = new QStackedWidget(m_splitter); + m_stack->setObjectName(QStringLiteral("viewStack")); + m_splitter->addWidget(m_rail); + m_splitter->addWidget(m_stack); + m_splitter->setStretchFactor(0, 0); + m_splitter->setStretchFactor(1, 1); + m_splitter->setCollapsible(0, false); + m_splitter->setCollapsible(1, false); + const int railWidth = + m_app.uiPrefs().rail_collapsed ? kRailMinWidth : kRailMaxWidth; + m_splitter->setSizes({railWidth, width() - railWidth}); + connect(m_rail, &ViewRailPanel::currentChanged, this, + &MainWindow::selectView); + connect(m_rail, &ViewRailPanel::hovered, this, &MainWindow::preloadView); + connect(m_rail, &ViewRailPanel::settingsRequested, this, + [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); + connect(m_rail, &ViewRailPanel::helpActionRequested, this, + [this](const QString& key) { + for (const auto& spec : actionSpecs()) + if (spec.key == key) m_actions.at(spec.id)->trigger(); + }); + connect(m_splitter, &QSplitter::splitterMoved, this, [this] { snapRail(); }); + for (const auto& spec : m_app.railViews()) { + auto* view = spec.create(m_ctx, m_engine, m_stack, m_app); + m_views.push_back(view); + m_stack->addWidget(view); + if (spec.meta.viewId == QLatin1StringView("mods")) + m_modsView = qobject_cast(view); + } + if (m_modsView) { + m_selection = + std::make_unique(m_ctx, *m_modsView->modInfo); + connect(m_modsView, &ModsViewPanel::entrySelected, this, + [this](const view::SidebarEntry& entry) { + m_modsView->applySidebarEntry(entry); + }); + connect(m_modsView, &ModsViewPanel::modSelected, this, + [this](const QString& uuid) { selectMod(uuid); }); + connect(m_modsView->modInfo, &ModInfoPanel::modRequested, this, + [this](const QString& uuid) { requestMod(uuid); }); + } + layout->addWidget(m_splitter, 1); + setCentralWidget(outer); + m_toasts = new ToastManager(outer); + m_toasts->resizeToParent(); + m_app.setToasts(m_toasts); + m_app.setNavigator([this](const QString& url) { openRoute(url); }); + m_activeConnection = m_ctx.activeStateChanged().connect( + [this](const auto&) { refreshUnsavedState(); }); + m_modsConnection = m_ctx.modService().modsChanged.connect( + [this](const auto&) { onModsReloaded(); }); + auto& diagnostics = m_ctx.diagnosticsService(); + m_diagnosticsConnection = + diagnostics.diagnosticsSummaryChanged.connect([this](const auto&) { + if (!m_modsView) return; + m_modsView->applyCurrentSidebarFilter(); + if (!m_selectedUuid.isEmpty()) + m_modsView->modInfo->setIssues( + m_ctx.diagnosticsService().issuesFor(m_selectedUuid)); + }); + m_statusConnection = diagnostics.statusMessageChanged.connect( + [this](const QString& message) { m_toasts->info(message); }); + m_taskConnection = diagnostics.backgroundTaskChanged.connect( + [this](const QString& text) { m_headerController->setTaskText(text); }); + auto& downloads = m_ctx.downloads(); + if (downloads.available()) { + m_downloadBusyConnection = downloads.busyChanged.connect([this](bool busy) { + m_headerController->setDownloadsBusy(busy); + m_actions.at(ActionId::kUpdateMods)->setEnabled(!busy); + if (busy) + m_headerController->setDownloadsProgress( + QStringLiteral("Updating mods…"), 0, 0); + }); + m_downloadProgressConnection = downloads.downloadProgress.connect( + [this](const DownloadProgress& progress) { + m_headerController->setDownloadsProgress( + QStringLiteral("Updating mods: %1 / %2 · %3 MB") + .arg(progress.completed) + .arg(progress.total) + .arg(progress.bytesDone / 1048576.0, 0, 'f', 0), + progress.completed, progress.total); + }); + m_downloadFinishedConnection = downloads.downloadFinished.connect( + [this](const auto& result) { onDownloadsFinished(result); }); + m_downloadStatusConnection = downloads.statusMessageChanged.connect( + [this](const QString& message) { m_toasts->info(message); }); + } + qApp->installEventFilter(this); + if (!m_views.empty()) showView(0); + refreshUnsavedState(); +} + +MainWindow::~MainWindow() { + qApp->removeEventFilter(this); + m_app.setNavigator({}); + m_app.setToasts(nullptr); + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); + auto& diagnostics = m_ctx.diagnosticsService(); + diagnostics.diagnosticsSummaryChanged.disconnect(m_diagnosticsConnection); + diagnostics.statusMessageChanged.disconnect(m_statusConnection); + diagnostics.backgroundTaskChanged.disconnect(m_taskConnection); + auto& downloads = m_ctx.downloads(); + downloads.busyChanged.disconnect(m_downloadBusyConnection); + downloads.downloadProgress.disconnect(m_downloadProgressConnection); + downloads.downloadFinished.disconnect(m_downloadFinishedConnection); + downloads.statusMessageChanged.disconnect(m_downloadStatusConnection); + const auto dialogs = + findChildren(QString(), Qt::FindDirectChildrenOnly); + for (auto* dialog : dialogs) delete dialog; + // QuickWidgets must release their scenes before the shared engine is + // destroyed. + delete takeCentralWidget(); + delete m_engine; +} + +void MainWindow::setupActions() { + m_actions = createActions(*this); + const auto bind = [this](ActionId id, auto handler) { + connect(m_actions.at(id), &QAction::triggered, this, handler); + }; + bind(ActionId::kSave, [this] { saveActiveMods(); }); + bind(ActionId::kRestore, [this] { restoreSnapshot(); }); + bind(ActionId::kSettings, + [this] { m_app.navigate(buildRoute(QString(kSettingsViewId))); }); + bind(ActionId::kQuit, [this] { close(); }); + bind(ActionId::kRefresh, [this] { reloadMods(); }); + bind(ActionId::kFullRescan, [this] { reloadMods(true); }); + bind(ActionId::kAutoSort, [this] { autoSort(); }); + bind(ActionId::kUpdateMods, [this] { updateMods(); }); + bind(ActionId::kFocusSearch, [this] { focusSearch(); }); + bind(ActionId::kNextView, [this] { cycleView(1); }); + bind(ActionId::kPrevView, [this] { cycleView(-1); }); + bind(ActionId::kFullscreen, + [this] { isFullScreen() ? showNormal() : showFullScreen(); }); + bind(ActionId::kReportIssue, [] { + QDesktopServices::openUrl( + QUrl(QStringLiteral("https://github.com/PyXiion/PxModRim/issues"))); + }); + bind(ActionId::kOpenLogs, [] { + QDesktopServices::openUrl(QUrl::fromLocalFile( + QDir(configDir()).filePath(QStringLiteral("logs")))); + }); + bind(ActionId::kUploadLogs, [this] { uploadReport(); }); + bind(ActionId::kShortcuts, [this] { showShortcuts(); }); + bind(ActionId::kCheckUpdates, [this] { checkUpdates(true); }); + bind(ActionId::kAbout, [this] { showAbout(); }); + m_actions.at(ActionId::kUpdateMods) + ->setEnabled(m_ctx.downloads().available()); + for (int index = 0; index < 9; ++index) { + auto* action = new QAction(this); + action->setShortcut(QKeySequence(QStringLiteral("Ctrl+%1").arg(index + 1))); + connect(action, &QAction::triggered, this, + [this, index] { selectView(index); }); + addAction(action); + } +} + +void MainWindow::setAppQuitCallback(std::function callback) { + m_quit = std::move(callback); +} + +void MainWindow::openRoute(const QString& url) { + const auto route = parseRoute(url); + if (!route) return; + if (route->viewId == kSettingsViewId) { + openSettings(); + return; + } + const auto& specs = m_app.railViews(); + for (std::size_t i = 0; i < m_views.size(); ++i) { + if (route->viewId == specs[i].meta.viewId) { + showView(static_cast(i)); + m_views[i]->openRoute(route->path); + return; + } + } +} + +void MainWindow::selectView(int index) { + if (index >= 0 && index < m_stack->count()) + m_app.navigate(buildRoute(QString(m_app.railViews()[index].meta.viewId))); +} + +void MainWindow::showView(int index) { + m_rail->setCurrent(index); + m_stack->setCurrentIndex(index); + m_headerController->setDownloadsProgressShown( + !m_app.railViews()[index].meta.listsDownloads); + preloadView(index - 1); + preloadView(index + 1); +} + +void MainWindow::preloadView(int index) { + if (index >= 0 && index < static_cast(m_views.size())) + m_views[index]->preload(); +} + +void MainWindow::cycleView(int direction) { + const int count = m_stack->count(); + if (count) + selectView(((m_stack->currentIndex() + direction) % count + count) % count); +} + +void MainWindow::openDownloads() { + for (const auto& view : m_app.railViews()) { + if (view.meta.listsDownloads) { + m_app.navigate(buildRoute(QString(view.meta.viewId))); + return; + } + } +} + +void MainWindow::focusSearch() { + auto* search = m_stack->currentWidget() + ? m_stack->currentWidget()->findChild( + QStringLiteral("searchInput")) + : nullptr; + if (search) + search->setFocus(); + else if (m_modsView) + m_modsView->modList->searchInput->setFocus(); +} + +void MainWindow::savePrefs() { + if (m_ctx.hasConfigService()) { + const auto result = saveUiPrefs(m_app.uiPrefs(), m_ctx.configService()); + if (!result) m_toasts->error(result.error().message); + } +} + +void MainWindow::snapRail() { + const int target = + m_rail->width() >= kRailCollapseWidth ? kRailMaxWidth : kRailMinWidth; + m_splitter->setSizes({target, m_splitter->width() - target}); + const bool collapsed = target == kRailMinWidth; + if (m_app.uiPrefs().rail_collapsed != collapsed) { + m_app.uiPrefs().rail_collapsed = collapsed; + savePrefs(); + } +} + +void MainWindow::refreshUnsavedState() { + m_unsaved = m_ctx.activeUuids() != m_savedActive; + m_headerController->setUnsavedChanges(m_unsaved); + setWindowModified(m_unsaved); +} + +void MainWindow::onModsReloaded() { + m_savedActive = m_ctx.loadedActiveUuids(); + refreshUnsavedState(); +} + +bool MainWindow::eventFilter(QObject* object, QEvent* event) { + if (event->type() == QEvent::KeyPress) { + auto* widget = qobject_cast(object); + if (widget && widget->window() == this) { + const int key = static_cast(event)->key(); + if (key == Qt::Key_Alt) { + m_menu->setVisible(!m_menu->isVisible()); + if (m_menu->isVisible()) m_menu->setFocus(); + return true; + } + if (key == Qt::Key_Escape && m_menu->isVisible()) { + m_menu->hide(); + return true; + } + } + } + return QMainWindow::eventFilter(object, event); +} + +void MainWindow::closeEvent(QCloseEvent* event) { + if (m_unsaved && !m_closeConfirmed) { + event->ignore(); + if (!m_closePromptOpen) { + m_closePromptOpen = true; + confirmClose(); + } + return; + } + m_closing = true; + for (auto* view : m_views) view->teardown(); + event->accept(); + if (m_quit) m_quit(); +} + +QCoro::Task MainWindow::confirmClose() { + const auto [result, dialog] = + co_await awaitDialog(this); + m_closePromptOpen = false; + if (result == QMessageBox::Save) { + if (!co_await saveActiveMods()) co_return; + } else if (result != QMessageBox::Discard) + co_return; + m_closeConfirmed = true; + close(); +} + +void MainWindow::resizeEvent(QResizeEvent* event) { + QMainWindow::resizeEvent(event); + if (m_toasts) m_toasts->resizeToParent(); +} + +void MainWindow::changeEvent(QEvent* event) { + QMainWindow::changeEvent(event); + if (event->type() == QEvent::WindowStateChange && m_headerController) + m_headerController->setMaximized(isMaximized()); +} + +QCoro::Task MainWindow::reloadMods(bool full) { + m_toasts->info(full ? QStringLiteral("Rescanning all mods…") + : QStringLiteral("Refreshing mods…")); + const auto count = co_await m_app.refreshMods(full); + if (!count) { + m_toasts->error(count.error().message); + co_return; + } + if (!*count) { + m_toasts->warning(QStringLiteral("No mods found")); + co_return; + } + m_toasts->success((full ? QStringLiteral("Rescanned %1 mods") + : QStringLiteral("Reloaded %1 mods")) + .arg(*count)); +} + +QCoro::Task MainWindow::autoSort() { + const auto [count, elapsed] = co_await m_ctx.autoSort(); + m_toasts->success(QStringLiteral("Sorted %1 mods in %2ms") + .arg(count) + .arg(elapsed, 0, 'f', 0)); +} + +QCoro::Task MainWindow::updateMods() { + auto& downloads = m_ctx.downloads(); + if (!downloads.available()) co_return; + if (downloads.isDownloading()) { + downloads.cancel(); + co_return; + } + const auto ids = downloads.updatableIds(m_ctx.allMods().values()); + if (ids.isEmpty()) { + m_toasts->info(QStringLiteral("No downloaded mods to update")); + co_return; + } + if (!co_await confirmUpdate(ids.size(), this)) co_return; + m_toasts->info(QStringLiteral("Updating %1 mods…").arg(ids.size())); + const auto result = co_await downloads.downloadMods(ids); + if (!result) m_toasts->error(result.error().message); +} + +void MainWindow::onDownloadsFinished(const DownloadResult& result) { + const auto ok = result.succeeded.size(), failed = result.failed.size(), + changed = result.changed.size(); + if (!ok && !failed) { + m_toasts->info(QStringLiteral("Download cancelled")); + return; + } + const QString summary = + QStringLiteral("%1 updated, %2 unchanged").arg(changed).arg(ok - changed); + if (failed) { + const auto names = m_ctx.downloads().namesById(m_ctx.allMods().values()); + QStringList shown; + for (const auto& id : result.failed | std::views::take(3)) + shown.append(names.value(id, id)); + m_toasts->warning(QStringLiteral("%1, %2 failed: %3%4") + .arg(summary) + .arg(failed) + .arg(shown.join(QStringLiteral(", ")), + failed > 3 ? QStringLiteral("…") : QString())); + } else + m_toasts->success(summary); + if (changed) m_app.refreshMods(); +} + +QCoro::Task MainWindow::openSettings() { + if (m_settingsOpen) co_return; + m_settingsOpen = true; + const auto finish = qScopeGuard([this] { m_settingsOpen = false; }); + const auto [result, dialog] = + co_await awaitDialog(m_app, m_engine, this); + if (result != QDialog::Accepted) co_return; + auto config = dialog->getConfig(); + const auto saved = + m_ctx.configService().save(QStringLiteral("config.json"), config); + if (!saved) { + m_toasts->error(saved.error().message); + co_return; + } + m_ctx.updateConfig(config); + m_ctx.resetProviders(config.paths); + const auto reloaded = co_await m_ctx.modService().reload(); + if (!reloaded) { + m_toasts->error(reloaded.error().message); + co_return; + } + if (m_startedWithoutGamePath && !m_app.pluginsInitialized() && + !m_ctx.config().paths.game.empty()) { + if (auto initialized = co_await m_app.initAll(); !initialized) { + m_toasts->error(initialized.error().message); + co_return; + } + startStartupUpdateCheck(); + } + m_toasts->success(QStringLiteral("Settings saved")); +} + +QCoro::Task MainWindow::showAbout() { + co_await awaitDialog(this, m_ctx, *m_toasts); +} +QCoro::Task MainWindow::showShortcuts() { + co_await awaitDialog(shortcutRows() + kQmlShortcuts, + this); +} +QCoro::Task MainWindow::uploadReport() { + co_await handleUploadReport(this, m_ctx, *m_toasts); +} + +QCoro::Task MainWindow::restoreSnapshot() { + const auto snapshots = m_ctx.modService().getSnapshots(); + if (snapshots.isEmpty()) { + m_toasts->warning(QStringLiteral("No saved mod lists are available")); + co_return; + } + const auto [result, dialog] = + co_await awaitDialog(snapshots, this); + if (result != QDialog::Accepted) co_return; + const auto chosen = dialog->selectedSnapshot(); + if (!chosen) co_return; + const auto [confirmed, confirmation] = + co_await awaitDialog(QFileInfo(*chosen).fileName(), + this); + if (confirmed != QMessageBox::Yes) co_return; + if (co_await m_ctx.modService().restoreSnapshot(*chosen)) + m_toasts->success(QStringLiteral("Restored mod list from %1") + .arg(QFileInfo(*chosen).fileName())); + else + m_toasts->error( + QStringLiteral("The saved mod list is invalid or cannot be read")); +} + +void MainWindow::startStartupUpdateCheck() { checkUpdates(false); } + +QCoro::Task MainWindow::checkUpdates(bool manual) { + if (m_checkingUpdates) co_return; + m_checkingUpdates = true; + const QPointer guard(this); + const auto release = co_await m_updates.check(); + if (!guard || m_closing) co_return; + if (!release) { + m_checkingUpdates = false; + if (manual) m_toasts->error(QStringLiteral("Could not check for updates")); + co_return; + } + if (!release->has_value()) { + m_checkingUpdates = false; + if (manual) m_toasts->success(QStringLiteral("PxModRim is up to date")); + co_return; + } + const auto info = **release; + if (!manual && info.tag.toStdString() == m_app.uiPrefs().skipped_update_tag) { + m_checkingUpdates = false; + co_return; + } + const auto [result, dialog] = co_await awaitDialog( + info, m_updates.currentVersion(), this); + if (!guard || m_closing) co_return; + m_checkingUpdates = false; + if (result == QDialog::Accepted) + QDesktopServices::openUrl(QUrl(info.url)); + else if (result == kSkipResult) { + m_app.uiPrefs().skipped_update_tag = info.tag.toStdString(); + savePrefs(); + } +} + +QCoro::Task> MainWindow::writeActiveLayout() { + const auto active = m_ctx.activeUuids(); + if (!co_await m_ctx.modService().saveActiveLayout(active)) + co_return std::nullopt; + m_savedActive = active; + refreshUnsavedState(); + co_return active; +} + +QCoro::Task MainWindow::saveActiveMods() { + const auto saved = co_await writeActiveLayout(); + if (!saved) { + m_toasts->warning(QStringLiteral("Config folder not set")); + co_return false; + } + m_toasts->success(QStringLiteral("Saved %1 active mods").arg(saved->size())); + co_return true; +} + +QCoro::Task MainWindow::confirmLaunch(bool AppConfig::* setting, + const QString& title, + const QString& text, + const QString& info, + const QString& accept) { + const auto [result, dialog] = co_await awaitDialog( + this, title, text, info, accept); + if (result != QMessageBox::Ok) co_return false; + if (dialog->dontAskAgain()) { + auto config = m_ctx.config(); + config.*setting = false; + const auto saved = + m_ctx.configService().save(QStringLiteral("config.json"), config); + if (!saved) { + m_toasts->error(saved.error().message); + co_return false; + } + m_ctx.updateConfig(std::move(config)); + } + co_return true; +} + +QCoro::Task MainWindow::launchGame() { + if (m_headerController->launchState() != QStringLiteral("idle")) co_return; + m_headerController->setLaunchState(QStringLiteral("launching")); + const QPointer guard(this); + auto& launcher = m_ctx.gameLauncher(); + const auto config = m_ctx.config(); + const bool running = + config.launch_confirm_running && + co_await QtConcurrent::run([&launcher] { return launcher.isRunning(); }); + if (!guard || m_closing) co_return; + const int errors = m_ctx.diagnosticsService().activeErrorCount(); + const auto abort = [this] { + m_headerController->setLaunchState(QStringLiteral("idle")); + }; + if (running && + !co_await confirmLaunch(&AppConfig::launch_confirm_running, + QStringLiteral("Game Already Running"), + QStringLiteral("RimWorld is already running."), + QStringLiteral("A second copy can overwrite the " + "first one's saves and settings."), + QStringLiteral("Launch Anyway"))) { + abort(); + co_return; + } + if (config.launch_confirm_errors && errors > 0 && + !co_await confirmLaunch( + &AppConfig::launch_confirm_errors, + QStringLiteral("Mod List Has Errors"), + QStringLiteral("%1 active mod(s) have errors.").arg(errors), + QStringLiteral( + "Missing dependencies or wrong load order can crash the game."), + QStringLiteral("Launch Anyway"))) { + abort(); + co_return; + } + if (config.launch_confirm_unsaved && m_unsaved && + !co_await confirmLaunch( + &AppConfig::launch_confirm_unsaved, QStringLiteral("Unsaved Changes"), + QStringLiteral("The active mod list has unsaved changes."), + QStringLiteral( + "The list is saved to ModsConfig.xml before the game starts."), + QStringLiteral("Save and Launch"))) { + abort(); + co_return; + } + if (!co_await writeActiveLayout()) + m_toasts->warning( + QStringLiteral("Config folder not set — mod list won't be saved")); + const auto result = co_await launcher.launch(m_app.uiPrefs().launch_strategy); + if (!guard || m_closing) co_return; + if (!result) { + m_toasts->warning(result.error().message); + abort(); + co_return; + } + m_toasts->success(*result); + const auto after = m_ctx.config().after_launch; + if (!co_await launcher.waitForStart()) { + if (guard && !m_closing) { + m_toasts->warning(QStringLiteral("Game did not start")); + abort(); + } + co_return; + } + if (!guard || m_closing) co_return; + m_headerController->setLaunchState(QStringLiteral("running")); + if (after == AfterLaunch::kClose && !m_unsaved) { + m_closeConfirmed = true; + close(); + co_return; + } + if (after == AfterLaunch::kMinimize) showMinimized(); + const auto code = co_await launcher.waitForExit(); + if (!guard || m_closing) co_return; + if (code && *code) + m_toasts->warning(QStringLiteral("Game exited with code %1").arg(*code)); + else + m_toasts->info(QStringLiteral("Game closed")); + if (after == AfterLaunch::kMinimize) { + showNormal(); + activateWindow(); + } + abort(); +} + +QCoro::Task MainWindow::selectMod(QString uuid) { + m_selectedUuid = uuid; + if (!m_selection) co_return; + if (uuid.isEmpty()) + m_selection->clear(); + else + co_await m_selection->show(std::move(uuid)); +} + +QCoro::Task MainWindow::requestMod(QString uuid) { + if (!m_modsView) co_return; + if (m_modsView->modList->selectUuid(uuid)) co_return; + m_modsView->modList->searchInput->clear(); + m_modsView->sidebar->selectAllEntry(); + if (!m_modsView->modList->selectUuid(uuid)) + co_await selectMod(std::move(uuid)); +} + +} // namespace px::modrim diff --git a/src/ui/window/main_window.hpp b/src/ui/window/main_window.hpp new file mode 100644 index 0000000..952dd30 --- /dev/null +++ b/src/ui/window/main_window.hpp @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/downloads/types.hpp" +#include "core/services/update_service.hpp" +#include "ui/context.hpp" +#include "ui/window/actions.hpp" + +class QQmlEngine; +class QSplitter; +class QStackedWidget; + +namespace px::modrim { + +class HeaderController; +class HeaderPanel; +class MenuBar; +class ModSelectionPresenter; +class ModsViewPanel; +class ToastManager; +class ViewRailPanel; + +class UnsavedChangesDialog : public QMessageBox { + public: + explicit UnsavedChangesDialog(QWidget* parent); +}; + +class LaunchConfirmDialog : public QMessageBox { + public: + LaunchConfirmDialog(QWidget* parent, const QString& title, + const QString& text, const QString& info, + const QString& accept); + bool dontAskAgain() const; +}; + +class MainWindow : public QMainWindow { + Q_OBJECT + public: + explicit MainWindow(AppContext& app, QWidget* parent = nullptr); + ~MainWindow() override; + + QQmlEngine* qmlEngine() const noexcept { return m_engine; } + void setAppQuitCallback(std::function callback); + void startStartupUpdateCheck(); + void openRoute(const QString& url); + void selectView(int index); + void cycleView(int direction); + void refreshUnsavedState(); + bool hasUnsavedChanges() const noexcept { return m_unsaved; } + QCoro::Task saveActiveMods(); + + protected: + bool eventFilter(QObject* object, QEvent* event) override; + void closeEvent(QCloseEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void changeEvent(QEvent* event) override; + + private: + void setupActions(); + void showView(int index); + void preloadView(int index); + void openDownloads(); + void focusSearch(); + void snapRail(); + void savePrefs(); + void onModsReloaded(); + void onDownloadsFinished(const DownloadResult& result); + QCoro::Task confirmClose(); + QCoro::Task reloadMods(bool full = false); + QCoro::Task autoSort(); + QCoro::Task updateMods(); + QCoro::Task openSettings(); + QCoro::Task showAbout(); + QCoro::Task showShortcuts(); + QCoro::Task uploadReport(); + QCoro::Task restoreSnapshot(); + QCoro::Task checkUpdates(bool manual); + QCoro::Task> writeActiveLayout(); + QCoro::Task confirmLaunch(bool AppConfig::* setting, + const QString& title, const QString& text, + const QString& info, const QString& accept); + QCoro::Task launchGame(); + QCoro::Task selectMod(QString uuid); + QCoro::Task requestMod(QString uuid); + + AppContext& m_app; + CoreContext& m_ctx; + const bool m_startedWithoutGamePath; + QQmlEngine* m_engine; + HeaderController* m_headerController = nullptr; + HeaderPanel* m_header = nullptr; + MenuBar* m_menu = nullptr; + ViewRailPanel* m_rail = nullptr; + QStackedWidget* m_stack = nullptr; + QSplitter* m_splitter = nullptr; + ToastManager* m_toasts = nullptr; + ModsViewPanel* m_modsView = nullptr; + std::vector m_views; + std::unique_ptr m_selection; + QString m_selectedUuid; + QStringList m_savedActive; + ActionMap m_actions; + std::function m_quit; + services::UpdateService m_updates; + bool m_unsaved = false; + bool m_closePromptOpen = false; + bool m_closeConfirmed = false; + bool m_closing = false; + bool m_settingsOpen = false; + bool m_checkingUpdates = false; + std::uint64_t m_activeConnection = 0; + std::uint64_t m_modsConnection = 0; + std::uint64_t m_diagnosticsConnection = 0; + std::uint64_t m_statusConnection = 0; + std::uint64_t m_taskConnection = 0; + std::uint64_t m_downloadBusyConnection = 0; + std::uint64_t m_downloadProgressConnection = 0; + std::uint64_t m_downloadFinishedConnection = 0; + std::uint64_t m_downloadStatusConnection = 0; +}; + +} // namespace px::modrim diff --git a/src/ui/window/menu_bar.cpp b/src/ui/window/menu_bar.cpp new file mode 100644 index 0000000..76df52d --- /dev/null +++ b/src/ui/window/menu_bar.cpp @@ -0,0 +1,38 @@ +#include "ui/window/menu_bar.hpp" + +#include + +namespace px::modrim { + +const std::array& menuLayout() { + static const std::array layout{{ + {"&File", + {ActionId::kSave, ActionId::kRestore, ActionId::kSettings, std::nullopt, + ActionId::kQuit}}, + {"&Mods", + {ActionId::kRefresh, ActionId::kFullRescan, ActionId::kAutoSort, + std::nullopt, ActionId::kUpdateMods}}, + {"&View", + {ActionId::kFocusSearch, ActionId::kNextView, ActionId::kPrevView, + std::nullopt, ActionId::kFullscreen}}, + {"&Help", + {ActionId::kReportIssue, ActionId::kUploadLogs, ActionId::kOpenLogs, + ActionId::kShortcuts, ActionId::kCheckUpdates, std::nullopt, + ActionId::kAbout}}, + }}; + return layout; +} + +MenuBar::MenuBar(const ActionMap& actions, QWidget* parent) : QMenuBar(parent) { + for (const auto& spec : menuLayout()) { + auto* menu = addMenu(spec.title); + for (auto id : spec.actions) { + if (id) + menu->addAction(actions.at(*id)); + else + menu->addSeparator(); + } + } +} + +} // namespace px::modrim diff --git a/src/ui/window/menu_bar.hpp b/src/ui/window/menu_bar.hpp new file mode 100644 index 0000000..fabf951 --- /dev/null +++ b/src/ui/window/menu_bar.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "ui/window/actions.hpp" + +namespace px::modrim { + +struct MenuSpec { + QString title; + std::vector> actions; +}; + +const std::array& menuLayout(); + +class MenuBar : public QMenuBar { + Q_OBJECT + public: + explicit MenuBar(const ActionMap& actions, QWidget* parent = nullptr); +}; + +} // namespace px::modrim diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..2d2cf0d --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,14 @@ +include(Catch) + +file(GLOB_RECURSE PX_CORE_TESTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/core/*.cpp) +add_executable(pxcore_tests ${PX_CORE_TESTS}) +target_link_libraries(pxcore_tests PRIVATE pxcore Catch2::Catch2WithMain) +catch_discover_tests(pxcore_tests) +target_compile_definitions(pxcore_tests PRIVATE PX_TEST_DATA_DIR="${CMAKE_CURRENT_SOURCE_DIR}/data") + +if(PX_BUILD_UI) + file(GLOB_RECURSE PX_UI_TESTS CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/ui/*.cpp) + add_executable(pxui_tests ${PX_UI_TESTS}) + target_link_libraries(pxui_tests PRIVATE pxui pxuiplugin Qt6::Test Catch2::Catch2) + catch_discover_tests(pxui_tests) +endif() diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/benchmarks/bench_discovery.py b/tests/benchmarks/bench_discovery.py deleted file mode 100644 index e424763..0000000 --- a/tests/benchmarks/bench_discovery.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import random -import string -import sys -import tempfile -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from loguru import logger - -logger.remove() - -from pxmodrim.core.models.metadata.parsing import ( # noqa: E402 - create_listed_mod_from_path, -) -from pxmodrim.core.services.mod_discovery import scan_mod_directory # noqa: E402 - -ABOUT_XML = """\ - - - {name} - {author} - {author}.{name} - https://steamcommunity.com/sharedfiles/filedetails/?id={sid} - -
  • 1.4
  • -
  • 1.5
  • - - -
  • {dep}
  • -
    - A benchmark mod #{i} - 1.0.0 -""" - - -def make_mod_dir(root: Path, i: int, dep_pid: str) -> Path: - author = random.choice(string.ascii_lowercase[:5]) - name = f"benchmark_mod_{i:04d}" - - about = ABOUT_XML.format(name=name, author=author, sid=i, dep=dep_pid, i=i) - - mod_dir = root / name - about_dir = mod_dir / "About" - about_dir.mkdir(parents=True, exist_ok=True) - (about_dir / "About.xml").write_text(about) - (about_dir / "PublishedFileId.txt").write_text(str(i)) - return mod_dir - - -def make_mods(root: Path, count: int) -> None: - """Create count mod directories with dependency chains.""" - prev_pid = "ludeon.rimworld" - for i in range(count): - make_mod_dir(root, i, prev_pid) - author = random.choice(string.ascii_lowercase[:5]) - prev_pid = f"{author}.benchmark_mod_{i:04d}" - - -def bench(count: int) -> None: - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) / "Mods" - root.mkdir() - make_mods(root, count) - - t0 = time.perf_counter_ns() - scanned = scan_mod_directory(root) - t1 = time.perf_counter_ns() - - mods = {} - t2 = time.perf_counter_ns() - for d, about in scanned.items(): - _, mod = create_listed_mod_from_path(d, "1.5", about_xml_path=about) - mods[mod.uuid] = mod - t3 = time.perf_counter_ns() - - scan_ms = (t1 - t0) / 1e6 - parse_ms = (t3 - t2) / 1e6 - total_ms = (t3 - t0) / 1e6 - print( - f"{count:>5} mods: scan_dir={scan_ms:>6.1f}ms" - f" parse_xml={parse_ms:>6.1f}ms total={total_ms:>6.1f}ms" - ) - print( - f" scan avg: {scan_ms / count * 1000:.1f} us/mod" - f" parse avg: {parse_ms / count * 1000:.1f} us/mod" - ) - - -if __name__ == "__main__": - for count in (50, 100, 200, 500, 1000, 2000): - bench(count) diff --git a/tests/benchmarks/bench_sort.py b/tests/benchmarks/bench_sort.py deleted file mode 100644 index c671044..0000000 --- a/tests/benchmarks/bench_sort.py +++ /dev/null @@ -1,134 +0,0 @@ -from __future__ import annotations - -import random -import sys -import time -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent.parent / "src")) - -from loguru import logger - -logger.remove() - -from pxmodrim.core.checker.graph import ConstraintGraph # noqa: E402 -from pxmodrim.core.checker.sort import topological_sort # noqa: E402 -from pxmodrim.core.models.metadata.structures import ( # noqa: E402 - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) -from pxmodrim.core.sort.config import SortSettings, TierConfig # noqa: E402 -from pxmodrim.core.sort.models import CommunityRule # noqa: E402 - -PackageId = CaseInsensitiveStr - - -def _tier_config(pids: list[PackageId]) -> TierConfig: - return TierConfig( - tier_0=tuple(pids[:3]) if len(pids) >= 3 else tuple(pids), - tier_1=tuple(pids[3:6]) if len(pids) >= 6 else (), - tier_3=(), - ) - - -def make_mods( - count: int, edges_per_mod: int -) -> tuple[ - dict[PackageId, AboutXmlMod], - dict[PackageId, CommunityRule], -]: - rng = random.Random(42) - pids = [PackageId(f"bench.mod.{i:04d}") for i in range(count)] - community_rules: dict[PackageId, CommunityRule] = {} - mods: dict[PackageId, AboutXmlMod] = {} - - for i, pid in enumerate(pids): - deps: dict[PackageId, DependencyMod] = {} - load_after = CaseInsensitiveSet() - - num_edges = min(edges_per_mod, i) - if num_edges: - targets = rng.sample(range(i), k=num_edges) - dep_count = max(1, num_edges // 2) - for idx in targets[:dep_count]: - deps[pids[idx]] = DependencyMod( - name=str(pids[idx]), - package_id=pids[idx], - ) - for idx in targets[dep_count:]: - load_after.add(pids[idx]) - - mods[pid] = AboutXmlMod( - name=f"Bench Mod {i:04d}", - package_id=pid, - provider_id="bench", - valid=True, - about_rules=BaseRules(dependencies=deps, load_after=load_after), - ) - - if rng.random() < 0.2: - cr_la: set[PackageId] = set() - if i > 0 and rng.random() < 0.5: - extra = rng.randint(1, min(2, i)) - for idx in rng.sample(range(i), k=extra): - cr_la.add(pids[idx]) - - community_rules[pid] = CommunityRule( - package_id=pid, - load_after=cr_la, - load_before=set(), - load_first=rng.random() < 0.1, - load_last=rng.random() < 0.1, - incompatible_with=set(), - ) - - return mods, community_rules - - -def bench(count: int, edges_per_mod: int) -> None: - mods, community_rules = make_mods(count, edges_per_mod) - pids = list(mods.keys()) - settings = SortSettings( - use_community_rules=True, - use_alternative_package_ids=False, - tier_config=_tier_config(pids), - ) - - graph = ConstraintGraph() - - t0 = time.perf_counter_ns() - graph.build(mods, pids, settings, community_rules) - t1 = time.perf_counter_ns() - - t2 = time.perf_counter_ns() - result = topological_sort(mods, graph, settings, community_rules) - t3 = time.perf_counter_ns() - - build_ms = (t1 - t0) / 1e6 - sort_ms = (t3 - t2) / 1e6 - total_ms = (t3 - t0) / 1e6 - - assert len(result) == count - - print( - f"{count:>5} mods ({edges_per_mod} e/m): " - f"graph={build_ms:>6.1f}ms " - f"sort={sort_ms:>6.1f}ms " - f"total={total_ms:>6.1f}ms" - ) - print( - f" avg per mod: " - f"graph={build_ms / count * 1000:>5.1f}us " - f"sort={sort_ms / count * 1000:>5.1f}us " - f"total={total_ms / count * 1000:>5.1f}us" - ) - - -if __name__ == "__main__": - print("=== bench_sort ===") - for count in (200, 500, 1000, 2000): - bench(count, edges_per_mod=5) - bench(2000, edges_per_mod=10) diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 6fdc7b5..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -import gc -import os -from collections.abc import Iterator -from pathlib import Path - -os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") -import pytest - -from pxmodrim.core.config import ConfigService - -os.environ.setdefault("QT_QUICK_CONTROLS_STYLE", "Basic") - - -@pytest.fixture -def config_service(tmp_path: Path) -> ConfigService: - """A ``ConfigService`` backed by a temporary directory. - - All services that would normally use ``~/.config/pxmodrim`` - are isolated to this temp directory instead. - """ - return ConfigService(tmp_path) - - -@pytest.fixture(autouse=True, scope="module") -def _collect_qobjects() -> Iterator[None]: - """Destroy unparented QObjects at module end, not at an arbitrary later GC.""" - yield - gc.collect() diff --git a/tests/core/checker/test_checker.cpp b/tests/core/checker/test_checker.cpp new file mode 100644 index 0000000..c84a685 --- /dev/null +++ b/tests/core/checker/test_checker.cpp @@ -0,0 +1,244 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/checker/checker.hpp" +#include "core/checker/graph.hpp" +#include "core/checker/issues.hpp" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" + +using namespace px::modrim; +using namespace px::modrim::checker; + +namespace { + +std::shared_ptr makeMod(const QString& packageId, + const QString& name = {}) { + auto m = std::make_shared(); + m->packageId = CaseInsensitiveStr(packageId); + m->name = name.isEmpty() ? packageId : name; + return m; +} + +sort::SortSettings settings() { + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = true; + s.check_missing_dependencies = true; + return s; +} + +QHash asListed( + const std::vector>& mods) { + QHash result; + for (std::size_t i = 0; i < mods.size(); ++i) { + result.insert(QString::number(i), mods[i]); + } + return result; +} + +QString uuidFor(const QHash& mods, const QString& name) { + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + if (it.value()->name == name) { + return it.key(); + } + } + return {}; +} + +std::vector> defaultCheckers() { + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + return checkers; +} + +} // namespace + +TEST_CASE("ModChecker basic rebuild") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.c")); + a->aboutRules.dependencies.append(dep); + + const auto mods = asListed({a, b}); + const QStringList activeUuids{ + uuidFor(mods, QStringLiteral("mod.a")), + uuidFor(mods, QStringLiteral("mod.b")), + }; + + DiagnosticsByUuid collected; + auto onChange = [&](const DiagnosticsByUuid& diags) { + for (auto it = diags.cbegin(); it != diags.cend(); ++it) { + collected.insert(it.key(), it.value()); + } + }; + + ModChecker checker(defaultCheckers(), settings(), QStringLiteral("1.5"), + onChange); + checker.rebuild(mods, activeUuids); + + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const auto* diagA = checker.diagnosticsFor(aUuid); + REQUIRE(diagA != nullptr); + CHECK(diagA->hasErrors()); + REQUIRE(diagA->errors.size() == 1); + CHECK(diagA->errors[0].category == QStringLiteral("missing_dependency")); + + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + const auto* diagB = checker.diagnosticsFor(bUuid); + REQUIRE(diagB != nullptr); + CHECK(!diagB->hasErrors()); + CHECK(!diagB->hasWarnings()); +} + +TEST_CASE("ModChecker toggle mod rebuilds diagnostics and constraints") { + std::vector> checkers; + checkers.push_back(std::make_unique()); + checkers.push_back(std::make_unique()); + + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + + const auto mods = asListed({a, b}); + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + + ModChecker checker(std::move(checkers), settings()); + checker.rebuild(mods, {aUuid, bUuid}); + + checker.toggleMod(*mods[bUuid], {aUuid}); + const auto* diagDisabled = checker.diagnosticsFor(aUuid); + REQUIRE(diagDisabled != nullptr); + CHECK(diagDisabled->hasErrors()); + CHECK(diagDisabled->errors[0].category == + QStringLiteral("missing_dependency")); + + checker.toggleMod(*mods[bUuid], {aUuid, bUuid}); + const auto* diagEnabled = checker.diagnosticsFor(aUuid); + REQUIRE(diagEnabled != nullptr); + CHECK(!diagEnabled->hasErrors()); + + QSet dependencySources; + for (const auto& edge : checker.graph().incomingOfType( + PackageId(QStringLiteral("mod.b")), EdgeType::kDependency)) { + dependencySources.insert(edge.source); + } + CHECK(dependencySources == + QSet{PackageId(QStringLiteral("mod.a"))}); + + QSet loadAfterTargets; + for (const auto& edge : checker.graph().edgesOfType( + PackageId(QStringLiteral("mod.b")), EdgeType::kLoadAfter)) { + loadAfterTargets.insert(edge.target); + } + CHECK(loadAfterTargets == + QSet{PackageId(QStringLiteral("mod.a"))}); +} + +TEST_CASE("ModChecker move mod updates load order") { + std::vector> checkers; + checkers.push_back(std::make_unique()); + + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + + // b first, then a -> satisfies load_after + const auto mods = asListed({b, a}); + const QString aUuid = uuidFor(mods, QStringLiteral("mod.a")); + const QString bUuid = uuidFor(mods, QStringLiteral("mod.b")); + + ModChecker checker(std::move(checkers), settings()); + checker.rebuild(mods, {bUuid, aUuid}); + + const auto* initial = checker.diagnosticsFor(aUuid); + CHECK((initial == nullptr || !initial->hasWarnings())); + + // Move a before b (old_index 1 -> new_index 0) + checker.moveMod(aUuid, 1, 0); + + const auto* afterMove = checker.diagnosticsFor(aUuid); + REQUIRE(afterMove != nullptr); + CHECK(afterMove->hasWarnings()); + CHECK(afterMove->warnings[0].category == QStringLiteral("load_order")); +} + +TEST_CASE("ModChecker reorder matches full rebuild") { + std::mt19937 rng(0); + constexpr int kModCount = 12; + std::vector> modsList; + modsList.reserve(kModCount); + for (int i = 0; i < kModCount; ++i) { + modsList.push_back(makeMod(QStringLiteral("mod.%1").arg(i))); + } + + for (int i = 0; i < kModCount; ++i) { + std::vector others; + for (int j = 0; j < kModCount; ++j) { + if (j != i) { + others.push_back(j); + } + } + std::ranges::shuffle(others, rng); + modsList[i]->aboutRules.loadAfter.add( + QStringLiteral("mod.%1").arg(others[0])); + modsList[i]->aboutRules.loadAfter.add( + QStringLiteral("mod.%1").arg(others[1])); + modsList[i]->aboutRules.loadBefore.add( + QStringLiteral("mod.%1").arg(others[2])); + } + + const auto mods = asListed(modsList); + QStringList order; + for (int i = 0; i < kModCount; ++i) { + order.append(QString::number(i)); + } + + ModChecker incremental(defaultCheckers(), settings()); + incremental.rebuild(mods, order); + + for (int step = 0; step < 30; ++step) { + if (std::uniform_real_distribution(0.0, 1.0)(rng) < 0.5) { + std::uniform_int_distribution pick(0, order.size() - 1); + const qsizetype i = pick(rng); + const qsizetype j = pick(rng); + order.swapItemsAt(i, j); + } else { + std::uniform_int_distribution pick(0, order.size() - 1); + const qsizetype from = pick(rng); + const qsizetype to = pick(rng); + order.insert(to, order.takeAt(from)); + } + incremental.reorder(order); + + ModChecker full(defaultCheckers(), settings()); + full.rebuild(mods, order); + + for (const QString& uuid : order) { + const auto* got = incremental.diagnosticsFor(uuid); + const auto* want = full.diagnosticsFor(uuid); + REQUIRE(got != nullptr); + REQUIRE(want != nullptr); + + CHECK(got->errors == want->errors); + CHECK(got->warnings == want->warnings); + } + } +} diff --git a/tests/core/checker/test_databases.cpp b/tests/core/checker/test_databases.cpp new file mode 100644 index 0000000..e0fd664 --- /dev/null +++ b/tests/core/checker/test_databases.cpp @@ -0,0 +1,170 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/checker/databases.hpp" +#include "core/config.hpp" + +using namespace px::modrim; +using namespace px::modrim::checker; + +namespace { + +std::string gzipCompress(std::string_view data) { + z_stream stream{}; + deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, + Z_DEFAULT_STRATEGY); + stream.next_in = reinterpret_cast(const_cast(data.data())); + stream.avail_in = static_cast(data.size()); + + std::string out; + std::vector buffer(16384); + int status = Z_OK; + do { + stream.next_out = reinterpret_cast(buffer.data()); + stream.avail_out = static_cast(buffer.size()); + status = deflate(&stream, Z_FINISH); + out.append(buffer.data(), buffer.size() - stream.avail_out); + } while (status != Z_STREAM_END); + deflateEnd(&stream); + return out; +} + +void writeFile(const QString& path, std::string_view content) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(content.data(), static_cast(content.size())); +} + +} // namespace + +TEST_CASE( + "NoVersionWarningService load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); +} + +TEST_CASE("NoVersionWarningService parses ModIdsToFix.xml") { + QTemporaryDir dir; + const QString xmlPath = + QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, + "" + "
  • brrainz.harmony
  • " + "
  • unlimitedhugs.hugslib
  • " + "
    "); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(service.cacheAgeS().has_value()); + CHECK(*service.cacheAgeS() >= 0.0); + + const auto pids = service.loadIfExists(); + CHECK(pids.size() == 2); + CHECK(pids.contains(PackageId(QStringLiteral("brrainz.harmony")))); + CHECK(pids.contains(PackageId(QStringLiteral("unlimitedhugs.hugslib")))); +} + +TEST_CASE("NoVersionWarningService parses single-item li") { + QTemporaryDir dir; + const QString xmlPath = + QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "
  • lone.mod
  • "); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + const auto pids = service.loadIfExists(); + CHECK(pids.size() == 1); + CHECK(pids.contains(PackageId(QStringLiteral("lone.mod")))); +} + +TEST_CASE("NoVersionWarningService handles malformed XML gracefully") { + QTemporaryDir dir; + const QString xmlPath = + QDir(dir.path()).filePath(QStringLiteral("ModIdsToFix.xml")); + writeFile(xmlPath, "not xml at all"); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningService service(config, network); + + CHECK(service.loadIfExists().isEmpty()); +} + +TEST_CASE( + "UseThisInsteadService load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); +} + +TEST_CASE("UseThisInsteadService parses gzipped replacements JSON") { + QTemporaryDir dir; + const QString gzPath = + QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + const std::string json = R"({ + "rules": [ + { + "oldWorkshopId": "123456", + "newName": "New Mod", + "newAuthor": "Author Guy", + "newPackageId": "author.newmod", + "newWorkshopId": "654321", + "newVersions": ["1.4", "1.5"] + } + ] + })"; + writeFile(gzPath, gzipCompress(json)); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(service.cacheAgeS().has_value()); + const auto entries = service.loadIfExists(); + REQUIRE(entries.size() == 1); + REQUIRE(entries.contains(QStringLiteral("123456"))); + + const ReplacementInfo& info = entries.value(QStringLiteral("123456")); + CHECK(info.name == QStringLiteral("New Mod")); + CHECK(info.author == QStringLiteral("Author Guy")); + CHECK(info.packageid == QStringLiteral("author.newmod")); + CHECK(info.pfid == QStringLiteral("654321")); + CHECK(info.supportedversions == + QStringList{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(info.source == QStringLiteral("database")); +} + +TEST_CASE("UseThisInsteadService handles corrupt gzip gracefully") { + QTemporaryDir dir; + const QString gzPath = + QDir(dir.path()).filePath(QStringLiteral("replacements.json.gz")); + writeFile(gzPath, "not gzip data"); + + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadService service(config, network); + + CHECK(service.loadIfExists().isEmpty()); +} diff --git a/tests/core/checker/test_graph.cpp b/tests/core/checker/test_graph.cpp new file mode 100644 index 0000000..195c1d9 --- /dev/null +++ b/tests/core/checker/test_graph.cpp @@ -0,0 +1,445 @@ +#include +#include +#include +#include +#include + +#include + +#include "core/checker/graph.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" + +using namespace px::modrim; +using namespace px::modrim::checker; + +namespace { + +std::shared_ptr makeMod(const QString& packageId) { + auto m = std::make_shared(); + m->packageId = CaseInsensitiveStr(packageId); + m->name = packageId; + return m; +} + +sort::SortSettings settings(bool useAlternativePackageIds = false) { + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = useAlternativePackageIds; + return s; +} + +} // namespace + +TEST_CASE("ConstraintGraph empty graph") { + ConstraintGraph g; + CHECK(g.nodes().isEmpty()); +} + +TEST_CASE("ConstraintGraph build no edges") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.a")))); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); + CHECK(g.nodes().size() == 2); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); +} + +TEST_CASE("ConstraintGraph dependency edge") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = + g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kDependency); + REQUIRE(edges.size() == 1); + const ConstraintEdge edge = *edges.cbegin(); + CHECK(edge.source == PackageId(QStringLiteral("mod.a"))); + CHECK(edge.target == PackageId(QStringLiteral("mod.b"))); + CHECK(edge.type == EdgeType::kDependency); + CHECK(edge.origin == EdgeOrigin::kAboutXml); +} + +TEST_CASE( + "ConstraintGraph dependency alternative resolution respects setting") { + ConstraintGraph g; + auto consumer = makeMod(QStringLiteral("mod.consumer")); + auto alternative = makeMod(QStringLiteral("mod.alternative")); + DependencyMod dependency; + dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.dependency")); + dependency.alternativePackageIds.add(QStringLiteral("mod.alternative")); + consumer->aboutRules.dependencies.append(dependency); + + QHash> mods{ + {PackageId(QStringLiteral("mod.consumer")), consumer}, + {PackageId(QStringLiteral("mod.alternative")), alternative}, + }; + const QList orderedPids{ + PackageId(QStringLiteral("mod.consumer")), + PackageId(QStringLiteral("mod.alternative")), + }; + + g.build(mods, orderedPids, settings(true)); + QSet enabledTargets; + for (const auto& edge : g.edgesOfType( + PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { + enabledTargets.insert(edge.target); + } + CHECK(enabledTargets == + QSet{PackageId(QStringLiteral("mod.alternative"))}); + + g.build(mods, orderedPids, settings(false)); + QSet disabledTargets; + for (const auto& edge : g.edgesOfType( + PackageId(QStringLiteral("mod.consumer")), EdgeType::kDependency)) { + disabledTargets.insert(edge.target); + } + CHECK(disabledTargets == + QSet{PackageId(QStringLiteral("mod.dependency"))}); +} + +TEST_CASE("ConstraintGraph resolves alternatives per dependency declaration") { + ConstraintGraph g; + const PackageId aPid(QStringLiteral("a.consumer")); + const PackageId bPid(QStringLiteral("b.consumer")); + const PackageId primaryPid(QStringLiteral("x")); + const PackageId yPid(QStringLiteral("y.alt")); + const PackageId zPid(QStringLiteral("z.alt")); + auto a = makeMod(aPid.str()); + auto b = makeMod(bPid.str()); + DependencyMod aDependency; + aDependency.packageId = primaryPid; + aDependency.alternativePackageIds.add(yPid.str()); + a->aboutRules.dependencies.append(aDependency); + DependencyMod bDependency; + bDependency.packageId = primaryPid; + bDependency.alternativePackageIds.add(zPid.str()); + b->aboutRules.dependencies.append(bDependency); + QHash> mods{ + {aPid, a}, + {bPid, b}, + {yPid, makeMod(yPid.str())}, + {zPid, makeMod(zPid.str())}, + }; + PackageId aTarget = yPid; + PackageId bTarget = zPid; + auto graphSettings = settings(true); + + SECTION("each consumer uses its own accepted alternative") {} + + SECTION("an active primary takes precedence") { + mods.insert(primaryPid, makeMod(primaryPid.str())); + aTarget = primaryPid; + bTarget = primaryPid; + } + + SECTION("another consumer's alternative cannot satisfy a declaration") { + mods.remove(zPid); + bTarget = primaryPid; + } + + SECTION("disabled alternative resolution keeps both primary edges") { + graphSettings.use_alternative_package_ids = false; + aTarget = primaryPid; + bTarget = primaryPid; + } + + g.build(mods, mods.keys(), graphSettings); + const auto aEdges = g.edgesOfType(aPid, EdgeType::kDependency); + REQUIRE(aEdges.size() == 1); + CHECK(aEdges.cbegin()->target == aTarget); + CHECK(aEdges.cbegin()->origin == EdgeOrigin::kAboutXml); + const auto bEdges = g.edgesOfType(bPid, EdgeType::kDependency); + REQUIRE(bEdges.size() == 1); + CHECK(bEdges.cbegin()->target == bTarget); + CHECK(bEdges.cbegin()->origin == EdgeOrigin::kAboutXml); +} + +TEST_CASE("ConstraintGraph retains alternative resolution for load rules") { + ConstraintGraph g; + const PackageId consumerPid(QStringLiteral("mod.consumer")); + const PackageId primaryPid(QStringLiteral("mod.primary")); + const PackageId alternativePid(QStringLiteral("mod.alternative")); + auto consumer = makeMod(consumerPid.str()); + DependencyMod dependency; + dependency.packageId = primaryPid; + dependency.alternativePackageIds.add(alternativePid.str()); + consumer->aboutRules.dependencies.append(dependency); + consumer->aboutRules.loadAfter.add(primaryPid.str()); + sort::CommunityRule rule; + rule.packageId = consumerPid; + rule.loadBefore.add(primaryPid.str()); + const sort::CommunityRules communityRules{{consumerPid, rule}}; + const QHash> mods{ + {consumerPid, consumer}, + {alternativePid, makeMod(alternativePid.str())}, + }; + auto graphSettings = settings(true); + graphSettings.use_community_rules = true; + + g.build(mods, mods.keys(), graphSettings, &communityRules); + const auto aboutEdges = g.edgesOfType(consumerPid, EdgeType::kLoadAfter); + REQUIRE(aboutEdges.size() == 1); + CHECK(aboutEdges.cbegin()->target == alternativePid); + CHECK(aboutEdges.cbegin()->origin == EdgeOrigin::kAboutXml); + const auto communityEdges = g.edgesOfType(consumerPid, EdgeType::kLoadBefore); + REQUIRE(communityEdges.size() == 1); + CHECK(communityEdges.cbegin()->target == alternativePid); + CHECK(communityEdges.cbegin()->origin == EdgeOrigin::kCommunityRules); +} + +TEST_CASE("ConstraintGraph incompatibility edge") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = g.edgesOfType(PackageId(QStringLiteral("mod.a")), + EdgeType::kIncompatibility); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); +} + +TEST_CASE("ConstraintGraph load after edge") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = + g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadAfter); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); +} + +TEST_CASE("ConstraintGraph load before edge") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto edges = + g.edgesOfType(PackageId(QStringLiteral("mod.a")), EdgeType::kLoadBefore); + REQUIRE(edges.size() == 1); + CHECK(edges.cbegin()->target == PackageId(QStringLiteral("mod.b"))); +} + +TEST_CASE("ConstraintGraph neighbors") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto nbrs = g.neighbors(PackageId(QStringLiteral("mod.a"))); + CHECK(nbrs.contains(PackageId(QStringLiteral("mod.b")))); + auto nbrsB = g.neighbors(PackageId(QStringLiteral("mod.b"))); + CHECK(nbrsB.contains(PackageId(QStringLiteral("mod.a")))); +} + +TEST_CASE("ConstraintGraph incoming") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + + auto inc = g.incoming(PackageId(QStringLiteral("mod.b"))); + REQUIRE(inc.size() == 1); + CHECK(inc.cbegin()->source == PackageId(QStringLiteral("mod.a"))); +} + +TEST_CASE("ConstraintGraph remove mod") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + CHECK(g.hasNode(PackageId(QStringLiteral("mod.b")))); + + g.removeMod(PackageId(QStringLiteral("mod.b"))); + CHECK(!g.hasNode(PackageId(QStringLiteral("mod.b")))); + CHECK(g.outgoing(PackageId(QStringLiteral("mod.a"))).isEmpty()); +} + +TEST_CASE("ConstraintGraph update order") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 1); + + g.updateOrder( + {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.a"))) == 1); + CHECK(g.indexOf(PackageId(QStringLiteral("mod.b"))) == 0); +} + +TEST_CASE("ConstraintGraph cycle detection") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); + c->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + {PackageId(QStringLiteral("mod.c")), c}, + }; + const QList order{ + PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.c")), + }; + g.build(mods, order, settings()); + auto cycles = g.findCycles(); + REQUIRE(cycles.size() >= 1); + QSet cyclePids; + for (const auto& cycle : cycles) { + for (const auto& p : cycle) { + cyclePids.insert(p); + } + } + for (const auto& name : {QStringLiteral("mod.a"), QStringLiteral("mod.b"), + QStringLiteral("mod.c")}) { + CHECK(cyclePids.contains(PackageId(name))); + } +} + +TEST_CASE("ConstraintGraph cycle detection reports overlapping cycles") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.a")); + b->aboutRules.loadAfter.add(QStringLiteral("mod.c")); + c->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + {PackageId(QStringLiteral("mod.c")), c}, + }; + const QList order{ + PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.c")), + }; + g.build(mods, order, settings()); + + auto cycles = g.findCycles(); + REQUIRE(cycles.size() == 1); + CHECK(QSet(cycles[0].cbegin(), cycles[0].cend()) == + QSet(order.cbegin(), order.cend())); +} + +TEST_CASE("ConstraintGraph no cycle") { + ConstraintGraph g; + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + QHash> mods{ + {PackageId(QStringLiteral("mod.a")), a}, + {PackageId(QStringLiteral("mod.b")), b}, + }; + g.build( + mods, + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}, + settings()); + auto cycles = g.findCycles(); + CHECK(cycles.isEmpty()); +} + +TEST_CASE("ConstraintGraph cycle detection handles long chain") { + constexpr int kCount = 5000; + QList nodes; + nodes.reserve(kCount); + QHash> mods; + for (int i = 0; i < kCount; ++i) { + PackageId pid(QStringLiteral("mod.%1").arg(i)); + nodes.append(pid); + mods.insert(pid, makeMod(pid.str())); + } + for (int i = 0; i < kCount - 1; ++i) { + mods[nodes[i]]->aboutRules.loadAfter.add(nodes[i + 1].str()); + } + ConstraintGraph g; + g.build(mods, nodes, settings()); + CHECK(g.findCycles().isEmpty()); +} diff --git a/tests/core/checker/test_issues.cpp b/tests/core/checker/test_issues.cpp new file mode 100644 index 0000000..9aa8efe --- /dev/null +++ b/tests/core/checker/test_issues.cpp @@ -0,0 +1,319 @@ +#include +#include +#include +#include +#include + +#include + +#include "core/checker/graph.hpp" +#include "core/checker/issues.hpp" +#include "core/checker/models.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" +#include "core/sort/models.hpp" + +using namespace px::modrim; +using namespace px::modrim::checker; + +namespace { + +std::shared_ptr makeMod(const QString& packageId) { + auto m = std::make_shared(); + m->packageId = CaseInsensitiveStr(packageId); + m->name = packageId; + return m; +} + +sort::SortSettings defaultSettings() { + sort::SortSettings s; + s.use_community_rules = false; + s.use_alternative_package_ids = true; + s.check_missing_dependencies = true; + return s; +} + +// Holds the storage referenced by a CheckContext so tests can construct one +// simply. +struct ContextStorage { + QHash> activeMods; + QList orderedPids; + QHash pidToIndex; + ConstraintGraph graph; + sort::SortSettings settings = defaultSettings(); + QString targetVersion = QStringLiteral("1.5"); + QSet noVersionWarning; + QHash useThisInstead; + QList> cycles; + + CheckContext ctx() const { + return CheckContext{ + .activeMods = activeMods, + .orderedPids = orderedPids, + .pidToIndex = pidToIndex, + .graph = graph, + .settings = settings, + .targetVersion = targetVersion, + .noVersionWarning = noVersionWarning, + .useThisInstead = useThisInstead, + .cycles = cycles, + }; + } +}; + +ContextStorage makeStorage( + QHash> mods, + std::optional> ordered = std::nullopt, + const sort::SortSettings* settings = nullptr, + const sort::CommunityRules* communityRules = nullptr) { + ContextStorage storage; + storage.activeMods = std::move(mods); + storage.orderedPids = ordered ? *ordered : storage.activeMods.keys(); + if (settings != nullptr) { + storage.settings = *settings; + } + for (qsizetype i = 0; i < storage.orderedPids.size(); ++i) { + storage.pidToIndex.insert(storage.orderedPids[i], static_cast(i)); + } + storage.graph.build(storage.activeMods, storage.orderedPids, storage.settings, + communityRules); + return storage; +} + +} // namespace + +TEST_CASE("DependencyIssueChecker no issues when mod has no dependencies") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + DependencyIssueChecker checker; + CHECK(!checker.shouldCheck(*a, storage.ctx())); + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("DependencyIssueChecker reports missing dependency") { + auto a = makeMod(QStringLiteral("mod.a")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + auto storage = makeStorage({{a->packageId, a}}); + + DependencyIssueChecker checker; + CHECK(checker.shouldCheck(*a, storage.ctx())); + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("missing_dependency")); + CHECK(issues[0].severity == IssueSeverity::kError); +} + +TEST_CASE("DependencyIssueChecker satisfied dependency yields no issues") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + a->aboutRules.dependencies.append(dep); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + + DependencyIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("IncompatibilityIssueChecker no incompatibilities") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + IncompatibilityIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("IncompatibilityIssueChecker self-declared incompatibility") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.incompatibleWith.add(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + + IncompatibilityIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("incompatibility")); + CHECK(issues[0].severity == IssueSeverity::kError); +} + +TEST_CASE("IncompatibilityIssueChecker reverse-declared incompatibility") { + auto declarer = makeMod(QStringLiteral("mod.declarer")); + auto target = makeMod(QStringLiteral("mod.target")); + declarer->aboutRules.incompatibleWith.add(QStringLiteral("mod.target")); + auto storage = makeStorage( + {{declarer->packageId, declarer}, {target->packageId, target}}); + + IncompatibilityIssueChecker checker; + auto issues = checker.check(*target, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("incompatibility")); + CHECK(issues[0].relatedPackageIds == + QList{PackageId(QStringLiteral("mod.declarer"))}); +} + +TEST_CASE( + "IncompatibilityIssueChecker reverse-declared deduplicated across " + "origins") { + auto declarer = makeMod(QStringLiteral("mod.declarer")); + auto target = makeMod(QStringLiteral("mod.target")); + const PackageId declarerPid(QStringLiteral("mod.declarer")); + const PackageId targetPid(QStringLiteral("mod.target")); + declarer->aboutRules.incompatibleWith.add(targetPid.str()); + + sort::SortSettings settings; + settings.use_community_rules = true; + settings.use_alternative_package_ids = true; + settings.check_missing_dependencies = true; + + sort::CommunityRule rule; + rule.packageId = declarerPid; + rule.incompatibleWith.add(targetPid.str()); + sort::CommunityRules cr{{declarerPid, rule}}; + + auto storage = makeStorage({{declarerPid, declarer}, {targetPid, target}}, + std::nullopt, &settings, &cr); + + auto incomingEdges = + storage.graph.incomingOfType(targetPid, EdgeType::kIncompatibility); + QSet origins; + for (const auto& edge : incomingEdges) { + origins.insert(edge.origin); + } + CHECK(origins == + QSet{EdgeOrigin::kAboutXml, EdgeOrigin::kCommunityRules}); + + auto issues = IncompatibilityIssueChecker().check(*target, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].relatedPackageIds == QList{declarerPid}); +} + +TEST_CASE("LoadOrderIssueChecker no violations when order satisfies rules") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + const QList ordered{PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.a"))}; + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); + + LoadOrderIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("LoadOrderIssueChecker load-after violation reported") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadAfter.add(QStringLiteral("mod.b")); + const QList ordered{PackageId(QStringLiteral("mod.a")), + PackageId(QStringLiteral("mod.b"))}; + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); + + LoadOrderIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("load_order")); + CHECK(issues[0].severity == IssueSeverity::kWarning); +} + +TEST_CASE("LoadOrderIssueChecker load-before violation reported") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + a->aboutRules.loadBefore.add(QStringLiteral("mod.b")); + // A should load before B, but appears after + const QList ordered{PackageId(QStringLiteral("mod.b")), + PackageId(QStringLiteral("mod.a"))}; + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}, ordered); + + LoadOrderIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("load_order")); + CHECK(issues[0].severity == IssueSeverity::kWarning); +} + +TEST_CASE("CycleIssueChecker no cycles") { + auto a = makeMod(QStringLiteral("mod.a")); + auto storage = makeStorage({{a->packageId, a}}); + CycleIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("CycleIssueChecker reports cycle membership") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto storage = makeStorage({{a->packageId, a}, {b->packageId, b}}); + storage.cycles = { + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; + + CycleIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("cycle")); + CHECK(issues[0].severity == IssueSeverity::kWarning); +} + +TEST_CASE("CycleIssueChecker non-member gets no issue") { + auto a = makeMod(QStringLiteral("mod.a")); + auto b = makeMod(QStringLiteral("mod.b")); + auto c = makeMod(QStringLiteral("mod.c")); + auto storage = + makeStorage({{a->packageId, a}, {b->packageId, b}, {c->packageId, c}}); + storage.cycles = { + {PackageId(QStringLiteral("mod.a")), PackageId(QStringLiteral("mod.b"))}}; + + CycleIssueChecker checker; + CHECK(checker.check(*c, storage.ctx()).isEmpty()); +} + +TEST_CASE("GameVersionIssueChecker no versions declared is not checked") { + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions.clear(); + auto storage = makeStorage({{a->packageId, a}}); + + GameVersionIssueChecker checker; + CHECK(!checker.shouldCheck(*a, storage.ctx())); + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("GameVersionIssueChecker matching version yields no issues") { + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.5")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); + + GameVersionIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("GameVersionIssueChecker mismatch reported as warning") { + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.4")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); + + GameVersionIssueChecker checker; + auto issues = checker.check(*a, storage.ctx()); + REQUIRE(issues.size() == 1); + CHECK(issues[0].category == QStringLiteral("version_mismatch")); + CHECK(issues[0].severity == IssueSeverity::kWarning); +} + +TEST_CASE("GameVersionIssueChecker suppressed by no_version_warning") { + auto a = makeMod(QStringLiteral("mod.a")); + a->supportedVersions = {QStringLiteral("1.4")}; + auto storage = makeStorage({{a->packageId, a}}); + storage.targetVersion = QStringLiteral("1.5"); + storage.noVersionWarning.insert(PackageId(QStringLiteral("mod.a"))); + + GameVersionIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} + +TEST_CASE("ReplacementIssueChecker no replacement without published file id") { + auto a = makeMod(QStringLiteral("mod.a")); + auto storage = makeStorage({{a->packageId, a}}); + ReplacementIssueChecker checker; + CHECK(checker.check(*a, storage.ctx()).isEmpty()); +} diff --git a/tests/core/downloads/test_manager.cpp b/tests/core/downloads/test_manager.cpp new file mode 100644 index 0000000..0d7285c --- /dev/null +++ b/tests/core/downloads/test_manager.cpp @@ -0,0 +1,188 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "../test_support.hpp" +#include "core/downloads/downloader.hpp" +#include "core/downloads/manager.hpp" +#include "core/downloads/types.hpp" +#include "core/models/metadata/structures.hpp" + +namespace { + +using px::modrim::Downloader; +using px::modrim::DownloadManager; +using px::modrim::DownloadResult; +using px::modrim::ListedMod; +using px::modrim::ListedModPtr; +using px::modrim::Result; +using test_support::runSync; + +// Claims ids that start with `prefix`, and records each batch it was asked to +// download. +class FakeDownloader : public Downloader { + public: + FakeDownloader(QString name, QString label, QString prefix, + DownloadManager& manager) + : Downloader(std::move(name), std::move(label), manager), + m_prefix(std::move(prefix)) {} + + bool isDownloading() const override { return m_busy; } + QSet activeIds() const override { return {}; } + bool accepts(const QString& modId) const override { + return modId.startsWith(m_prefix); + } + + std::optional updatableId(const ListedMod& mod) const override { + const auto& path = mod.modPath(); + if (!path) { + return std::nullopt; + } + const QString id = QFileInfo(*path).fileName(); + return accepts(id) ? std::optional(id) : std::nullopt; + } + + std::optional lastSynced(const ListedMod& mod) const override { + return updatableId(mod) ? std::optional(1.0) : std::nullopt; + } + + QCoro::Task> downloadMods( + QStringList modIds) override { + calls.push_back(modIds); + m_busy = true; + busyChanged.notify(true); + m_busy = false; + busyChanged.notify(false); + DownloadResult result{.succeeded = modIds, .failed = {}, .changed = {}}; + downloadFinished.notify(result); + co_return result; + } + + void cancel() override { cancelled = true; } + + std::vector calls; + bool cancelled = false; + + private: + QString m_prefix; + bool m_busy = false; +}; + +class FakeSteam : public FakeDownloader { + public: + explicit FakeSteam(DownloadManager& manager) + : FakeDownloader("steam", "Steam", "s", manager) {} +}; + +class FakeGithub : public FakeDownloader { + public: + explicit FakeGithub(DownloadManager& manager) + : FakeDownloader("github", "Github", "g", manager) {} +}; + +ListedModPtr makeMod(const QString& folder) { + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(QStringLiteral("/mods/%1").arg(folder)).has_value()); + return mod; +} + +} // namespace + +TEST_CASE("empty manager offers nothing", "[downloads][manager]") { + DownloadManager manager; + REQUIRE_FALSE(manager.available()); + REQUIRE(manager.updatableIds({makeMod("s1")}).isEmpty()); + REQUIRE(manager.sourceOf(*makeMod("s1")) == nullptr); +} + +TEST_CASE("download routes ids to the accepting downloader", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.registerDownloader(steam); + manager.registerDownloader(github); + + auto result = runSync(manager.downloadMods({"s1", "g1", "s2", "s1"})); + + REQUIRE(result.has_value()); + REQUIRE(steam.calls == std::vector{{"s1", "s2"}}); + REQUIRE(github.calls == std::vector{{"g1"}}); + QStringList succeeded = result->succeeded; + succeeded.sort(); + REQUIRE(succeeded == QStringList{"g1", "s1", "s2"}); +} + +TEST_CASE("unclaimed id is rejected before anything starts", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + manager.registerDownloader(steam); + + auto result = runSync(manager.downloadMods({"s1", "x1"})); + + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("x1")); + REQUIRE(steam.calls.empty()); +} + +TEST_CASE("source and ids come from the owning downloader", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.registerDownloader(steam); + manager.registerDownloader(github); + const auto g1 = makeMod("g1"); + const auto other = makeMod("other"); + const QList mods{makeMod("s1"), g1, other}; + + const Downloader* source = manager.sourceOf(*g1); + REQUIRE(source != nullptr); + REQUIRE(source->label() == "Github"); + REQUIRE(manager.updatableIds(mods) == QStringList{"s1", "g1"}); + REQUIRE_FALSE(manager.lastSynced(*other).has_value()); +} + +TEST_CASE("busy reflects any downloader and cancel stops the rest", + "[downloads][manager]") { + DownloadManager manager; + FakeSteam steam(manager); + FakeGithub github(manager); + manager.registerDownloader(steam); + manager.registerDownloader(github); + std::vector busy; + manager.busyChanged.connect( + [&busy](const bool& value) { busy.push_back(value); }); + + steam.downloadFinished.connect( + [&manager](const DownloadResult&) { manager.cancel(); }); + auto result = runSync(manager.downloadMods({"s1", "g1"})); + + REQUIRE(result.has_value()); + REQUIRE(github.calls.empty()); + REQUIRE(busy == std::vector{true, false}); +} + +TEST_CASE("names are keyed by source id, not workshop id", + "[downloads][manager]") { + DownloadManager manager; + FakeGithub github(manager); + manager.registerDownloader(github); + const auto mod = makeMod("g1"); + mod->name = QStringLiteral("Git Mod"); + + const QHash names = manager.namesById({mod, makeMod("s1")}); + + REQUIRE(names == QHash{{"g1", "Git Mod"}}); +} diff --git a/tests/core/downloads/test_steam.cpp b/tests/core/downloads/test_steam.cpp new file mode 100644 index 0000000..ae5a064 --- /dev/null +++ b/tests/core/downloads/test_steam.cpp @@ -0,0 +1,855 @@ +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include "../test_support.hpp" +#include "core/config.hpp" +#include "core/downloads/manager.hpp" +#include "core/downloads/steam.hpp" +#include "core/downloads/types.hpp" +#include "core/models/metadata/structures.hpp" + +namespace { + +using namespace std::chrono_literals; + +using px::modrim::ConfigService; +using px::modrim::DownloadItemStatus; +using px::modrim::DownloadManager; +using px::modrim::DownloadProgress; +using px::modrim::DownloadResult; +using px::modrim::ListedMod; +using px::modrim::ListedModPtr; +using px::modrim::LoginOptions; +using px::modrim::Result; +using px::modrim::SteamBatch; +using px::modrim::SteamDownloader; +using px::modrim::SteamDownloaderDeps; +using px::modrim::SteamSettings; +using px::modrim::WorkshopClient; +using px::modrim::WorkshopSyncState; +using test_support::TestApplication; + +// Thread-safe flag with a timeout-aware wait. +class Flag { + public: + void set() { + { + std::lock_guard lock(m_mutex); + m_value = true; + } + m_cv.notify_all(); + } + + void clear() { + std::lock_guard lock(m_mutex); + m_value = false; + } + + bool isSet() const { + std::lock_guard lock(m_mutex); + return m_value; + } + + bool waitFor(std::chrono::milliseconds timeout) { + std::unique_lock lock(m_mutex); + return m_cv.wait_for(lock, timeout, [this] { return m_value; }); + } + + private: + mutable std::mutex m_mutex; + std::condition_variable m_cv; + bool m_value = false; +}; + +// Drives an event loop until `task` co_returns. Fails if `timeout` expires. +template +QCoro::Task drive(QCoro::Task task, std::optional& out, + QEventLoop& loop) { + out.emplace(co_await std::move(task)); + loop.quit(); +} + +template +T runLoop(QCoro::Task task, std::chrono::milliseconds timeout = 10s) { + std::optional out; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); + timer.start(timeout); + + auto driver = drive(std::move(task), out, loop); + if (!out.has_value()) { + loop.exec(); + } + REQUIRE(out.has_value()); + return std::move(*out); +} + +inline QCoro::Task driveVoid(QCoro::Task task, bool& done, + QEventLoop& loop) { + co_await std::move(task); + done = true; + loop.quit(); +} + +inline void runLoopVoid(QCoro::Task task, + std::chrono::milliseconds timeout = 10s) { + bool done = false; + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, [&loop] { loop.quit(); }); + timer.start(timeout); + + auto driver = driveVoid(std::move(task), done, loop); + if (!done) { + loop.exec(); + } + REQUIRE(done); +} + +class FakeClient : public WorkshopClient { + public: + struct Call { + std::vector ids; + QString root; + int workers = 0; + }; + + QHash errors; + QHash> gates; + QSet networkFailures; + std::shared_ptr blocked = std::make_shared(); + + std::vector calls; + + std::vector download( + std::span ids, const std::filesystem::path& root, + const pxsteamdl::Options& options) override { + calls.push_back(Call{ + .ids = std::vector(ids.begin(), ids.end()), + .root = QString::fromStdWString(root.wstring()), + .workers = + static_cast(options.parallel_items * options.threads_per_item), + }); + + std::vector results; + results.reserve(ids.size()); + for (const auto id : ids) { + if (options.on_resolved) { + pxsteamdl::ItemInfo info; + info.item_id = id; + info.title = "Title " + std::to_string(id); + options.on_resolved(info); + } + if (options.on_progress) { + pxsteamdl::Progress p; + p.item_id = id; + p.unpacked_bytes = 5; + p.unpacked_total = 10; + options.on_progress(p); + } + if (auto gate = gates.value(id)) { + blocked->set(); + gate->waitFor(5s); + } + + const bool cancelled = options.stop.stop_requested(); + const QString errorText = + cancelled ? QStringLiteral("cancelled") : errors.value(id); + if (errorText.isEmpty()) { + std::error_code ec; + std::filesystem::create_directories(root / std::to_string(id), ec); + } + + pxsteamdl::ErrorKind kind = pxsteamdl::ErrorKind::kNone; + if (networkFailures.contains(id)) { + kind = pxsteamdl::ErrorKind::kNetwork; + } else if (cancelled) { + kind = pxsteamdl::ErrorKind::kCancelled; + } else if (!errorText.isEmpty()) { + kind = pxsteamdl::ErrorKind::kOther; + } + + results.push_back(pxsteamdl::Result{ + .item_id = id, + .title = "Title " + std::to_string(id), + .path = root / std::to_string(id), + .error = errorText.toStdString(), + .error_kind = kind, + .downloaded_bytes = 0, + .unpacked_bytes = 0, + }); + } + return results; + } +}; + +class DyingClient : public WorkshopClient { + public: + std::vector download(std::span, + const std::filesystem::path&, + const pxsteamdl::Options&) override { + throw std::runtime_error("session lost"); + } +}; + +struct Service { + explicit Service(std::deque> clients, + bool local = true) + : configDir(), + config(configDir.path()), + manager(), + localPath(local ? configDir.path() + "/Mods" : QString()), + clients(std::move(clients)) { + SteamDownloaderDeps deps{ + .manager = manager, + .config = config, + .localModsPath = [this] { return localPath; }, + .installedMods = [this] { return mods; }, + .clientFactory = + [this](LoginOptions opts) { return login(std::move(opts)); }, + }; + steam = std::make_unique(std::move(deps)); + manager.registerDownloader(*steam); + } + + QCoro::Task>> login( + LoginOptions opts) { + logins.push_back(opts); + if (loginGate.isValid()) { + co_await QFuture(loginGate); + } + if (clients.empty()) { + co_return std::unexpected( + px::modrim::Error{QStringLiteral("logon denied")}); + } + auto client = clients.front(); + clients.pop_front(); + co_return client; + } + + QTemporaryDir configDir; + ConfigService config; + DownloadManager manager; + QString localPath; + QList mods; + + std::vector logins; + std::deque> clients; + QFuture loginGate; + std::unique_ptr steam; +}; + +ListedModPtr makeModAt(const QString& path, const QString& pfid = {}) { + QDir().mkpath(path); + if (!pfid.isEmpty()) { + QDir().mkpath(path + "/About"); + test_support::writeBytes(path + "/About/PublishedFileId.txt", + pfid.toStdString()); + } + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(path).has_value()); + return mod; +} + +std::optional syncedOf(Service& svc, const QString& pid) { + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(svc.localPath + "/" + pid).has_value()); + return svc.steam->lastSynced(*mod); +} + +} // namespace + +TEST_CASE("login uses configured proxy and timeouts", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client, client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 8, + .threads_per_item = 1, + .proxy = " socks5h://host:1080 ", + .connect_timeout = 3, + .stall_timeout = 7, + }); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); + + const std::vector expected{ + LoginOptions{ + .proxy = std::nullopt, .connectTimeout = 10, .stallTimeout = 30}, + LoginOptions{.proxy = "socks5h://host:1080", + .connectTimeout = 3, + .stallTimeout = 7}, + }; + REQUIRE(svc.logins == expected); +} + +TEST_CASE("network failure triggers new login", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->networkFailures.insert(111); + Service svc({client, client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); + + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("downloads into local mods and splits results", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->errors.insert(222, QStringLiteral("item not found")); + Service svc({client}); + + std::vector statuses; + std::vector finished; + svc.steam->downloadItemStatusChanged.connect( + [&](const auto& s) { statuses.push_back(s); }); + svc.steam->downloadFinished.connect( + [&](const auto& r) { finished.push_back(r); }); + + auto result = runLoop(svc.steam->downloadMods({"111", "222", "111"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"111"}); + REQUIRE(result->failed == QStringList{"222"}); + REQUIRE(finished.size() == 1); + REQUIRE(finished[0] == *result); + REQUIRE(QFileInfo::exists(svc.localPath + "/111")); + REQUIRE(client->calls.size() == 1); + REQUIRE(client->calls[0].ids == std::vector{111, 222}); + REQUIRE(client->calls[0].root == svc.localPath); + + QHash finalStatus; + for (const auto& s : statuses) { + if (s.status != "downloading") { + finalStatus.insert(s.modId, s); + } + } + REQUIRE(finalStatus.value("111").status == "success"); + REQUIRE(finalStatus.value("222").status == "error"); + REQUIRE(finalStatus.value("222").error == "item not found"); +} + +TEST_CASE("rejects invalid ids", "[downloads][steam]") { + TestApplication app; + const std::vector badInputs{ + {"12a"}, {"\u0661\u0662\u0663"}, {"1 2"}, {}, {"18446744073709551616"}}; + for (const auto& ids : badInputs) { + Service svc({std::make_shared()}); + auto result = runLoop(svc.steam->downloadMods(ids)); + REQUIRE_FALSE(result.has_value()); + REQUIRE(svc.logins.empty()); + } +} + +TEST_CASE("requires local mods path", "[downloads][steam]") { + TestApplication app; + Service svc({std::make_shared()}, /*local=*/false); + + auto result = runLoop(svc.steam->downloadMods({"111"})); + + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Local mods path")); + REQUIRE(svc.logins.empty()); +} + +TEST_CASE("accepts uint64 max id", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + auto result = runLoop(svc.steam->downloadMods({"18446744073709551615"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"18446744073709551615"}); + REQUIRE(client->calls[0].ids == std::vector{UINT64_MAX}); +} + +TEST_CASE("unwritable local path fails every item", "[downloads][steam]") { + TestApplication app; + Service svc({std::make_shared()}); + test_support::writeBytes(svc.localPath, "not a directory"); + + auto result = runLoop(svc.steam->downloadMods({"111", "222"})); + + REQUIRE(result.has_value()); + QStringList failed = result->failed; + failed.sort(); + REQUIRE(failed == QStringList{"111", "222"}); + REQUIRE(svc.logins.empty()); +} + +TEST_CASE("login failure fails every item and retries next time", + "[downloads][steam]") { + TestApplication app; + Service svc({}); + std::vector messages; + svc.steam->statusMessageChanged.connect( + [&](const QString& m) { messages.push_back(m); }); + + auto first = runLoop(svc.steam->downloadMods({"111", "222"})); + auto second = runLoop(svc.steam->downloadMods({"111"})); + + REQUIRE(first.has_value()); + REQUIRE(first->succeeded.isEmpty()); + QStringList failed = first->failed; + failed.sort(); + REQUIRE(failed == QStringList{"111", "222"}); + + const bool hasDenied = std::ranges::any_of( + messages, [](const auto& m) { return m.contains("logon denied"); }); + REQUIRE(hasDenied); + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("client session is reused", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + REQUIRE(runLoop(svc.steam->downloadMods({"222"})).has_value()); + REQUIRE(svc.logins.size() == 1); +} + +TEST_CASE("uses at most eight download workers", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QStringList ids; + for (int i = 1; i <= 20; ++i) { + ids.append(QString::number(i)); + } + + REQUIRE(runLoop(svc.steam->downloadMods(ids)).has_value()); + REQUIRE(client->calls.size() == 1); + REQUIRE(client->calls[0].workers <= 8); +} + +TEST_CASE("cancel keeps finished items and returns", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + auto gate = std::make_shared(); + client->gates.insert(222, gate); + Service svc({client}); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (client->blocked->isSet()) { + svc.steam->cancel(); + gate->set(); + poll.stop(); + } + }); + poll.start(10ms); + + auto result = runLoop(svc.steam->downloadMods({"111", "222", "333"})); + + REQUIRE(result.has_value()); + REQUIRE(result->succeeded == QStringList{"111"}); + REQUIRE(result->failed.isEmpty()); + REQUIRE_FALSE(svc.steam->isDownloading()); +} + +TEST_CASE("shutdown waits for a batch suspended in login", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + SECTION("login succeeds") {} + SECTION("login fails") { svc.clients.clear(); } + + QPromise loginGate; + loginGate.start(); + svc.loginGate = loginGate.future(); + + bool shutdownDone = false; + int eventsAfterShutdown = 0; + const auto observe = [&](const auto&) { + if (shutdownDone) { + ++eventsAfterShutdown; + } + }; + svc.steam->busyChanged.connect(observe); + svc.steam->batchStarted.connect(observe); + svc.steam->statusMessageChanged.connect(observe); + svc.steam->downloadPhaseChanged.connect(observe); + svc.steam->downloadProgress.connect(observe); + svc.steam->downloadItemStatusChanged.connect(observe); + svc.steam->downloadItemTitled.connect(observe); + svc.steam->downloadFinished.connect(observe); + + std::optional> downloadOut; + auto download = test_support::awaitInto( + svc.steam->downloadMods({"111", "222"}), downloadOut); + + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &loop, [&] { + if (!svc.logins.empty()) { + loop.quit(); + } + }); + timeout.start(10s); + poll.start(1ms); + if (svc.logins.empty()) { + loop.exec(); + } + poll.stop(); + CHECK(svc.logins.size() == 1); + CHECK_FALSE(downloadOut.has_value()); + + bool downloadCompletedBeforeShutdown = false; + const auto finishShutdown = [&]() -> QCoro::Task { + co_await svc.steam->shutdown(); + downloadCompletedBeforeShutdown = downloadOut.has_value(); + shutdownDone = true; + loop.quit(); + }; + auto shutdown = finishShutdown(); + CHECK_FALSE(shutdownDone); + + loginGate.finish(); + timeout.start(10s); + if (!shutdownDone) { + loop.exec(); + } + REQUIRE(shutdownDone); + CHECK(downloadCompletedBeforeShutdown); + REQUIRE(downloadOut.has_value()); + REQUIRE(downloadOut->has_value()); + CHECK((*downloadOut)->succeeded.isEmpty()); + CHECK((*downloadOut)->failed == QStringList{"111", "222"}); + CHECK(client->calls.empty()); + CHECK_FALSE(svc.steam->isDownloading()); + CHECK_FALSE(runLoop(svc.steam->downloadMods({"333"})).has_value()); + + QMetaObject::invokeMethod(&loop, [&] { loop.quit(); }, Qt::QueuedConnection); + loop.exec(); + CHECK(eventsAfterShutdown == 0); +} + +TEST_CASE("cancel during login does not start a workshop download", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QPromise loginGate; + loginGate.start(); + svc.loginGate = loginGate.future(); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (!svc.logins.empty()) { + poll.stop(); + svc.steam->cancel(); + loginGate.finish(); + } + }); + poll.start(1ms); + + auto result = runLoop(svc.steam->downloadMods({"111", "222"})); + + REQUIRE(result.has_value()); + CHECK(result->succeeded.isEmpty()); + CHECK(result->failed == QStringList{"111", "222"}); + CHECK(client->calls.empty()); + CHECK_FALSE(svc.steam->isDownloading()); + REQUIRE(runLoop(svc.steam->downloadMods({"333"})).has_value()); + REQUIRE(client->calls.size() == 1); + CHECK(client->calls.front().ids == std::vector{333}); +} + +TEST_CASE("second download while running is rejected", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + auto gate = std::make_shared(); + client->gates.insert(111, gate); + Service svc({client}); + + std::optional> firstOut; + std::optional> secondOut; + QEventLoop loop; + + auto firstTask = + test_support::awaitInto(svc.steam->downloadMods({"111"}), firstOut); + + QTimer poll; + QObject::connect(&poll, &QTimer::timeout, &poll, [&] { + if (svc.steam->isDownloading()) { + poll.stop(); + auto secondTask = + test_support::awaitInto(svc.steam->downloadMods({"222"}), secondOut); + gate->set(); + loop.quit(); + } + }); + poll.start(10ms); + loop.exec(); + + REQUIRE(secondOut.has_value()); + REQUIRE_FALSE(secondOut->has_value()); + REQUIRE(secondOut->error().message.contains("already running")); +} + +TEST_CASE("updatable_id only for downloads in local mods", + "[downloads][steam]") { + TestApplication app; + Service svc({}); + const QString root = svc.localPath; + const auto m1 = makeModAt(root + "/111", "111"); + const auto m2 = makeModAt(root + "/Renamed", "222"); + const auto m3 = makeModAt(root + "/333"); + const auto m4 = makeModAt(svc.configDir.path() + "/Workshop/444", "444"); + svc.mods = {m1, m2, m3, m4}; + + REQUIRE(svc.steam->updatableId(*m1) == std::optional("111")); + REQUIRE(svc.steam->updatableId(*m2) == std::nullopt); + REQUIRE(svc.steam->updatableId(*m3) == std::optional("333")); + REQUIRE(svc.steam->updatableId(*m4) == std::nullopt); + + REQUIRE(svc.steam->updatableIds(svc.mods) == QStringList{"111", "333"}); +} + +TEST_CASE("updatable_id none without local path", "[downloads][steam]") { + TestApplication app; + Service svc({}, /*local=*/false); + const auto mod = makeModAt(svc.configDir.path() + "/Mods/111", "111"); + REQUIRE(svc.steam->updatableId(*mod) == std::nullopt); +} + +TEST_CASE("busy changed brackets download", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + std::vector events; + svc.steam->busyChanged.connect( + [&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); + svc.steam->downloadFinished.connect( + [&](const auto&) { events.push_back("finished"); }); + + REQUIRE(runLoop(svc.steam->downloadMods({"111"})).has_value()); + + const std::vector expected{"busy:1", "busy:0", "finished"}; + REQUIRE(events == expected); +} + +TEST_CASE("invalid ids do not toggle busy", "[downloads][steam]") { + TestApplication app; + Service svc({std::make_shared()}); + std::vector busy; + svc.steam->busyChanged.connect([&](bool b) { busy.push_back(b); }); + + auto result = runLoop(svc.steam->downloadMods({"12a"})); + REQUIRE_FALSE(result.has_value()); + REQUIRE(busy.empty()); +} + +TEST_CASE("progress counts complete and up to date items", + "[downloads][steam]") { + SteamBatch batch(3); + batch.bytes.insert("1", {10, 10}); + batch.bytes.insert("2", {5, 10}); + batch.bytes.insert("3", {0, 0}); + REQUIRE(batch.progress().completed == 2); + + batch.succeeded.append("1"); + REQUIRE(batch.progress().completed == 2); +} + +TEST_CASE("updatable_ids limited to given mods and deduplicated", + "[downloads][steam]") { + TestApplication app; + Service svc({}); + const auto a = makeModAt(svc.localPath + "/111", "111"); + const auto b = makeModAt(svc.localPath + "/222", "222"); + const auto other = makeModAt(svc.configDir.path() + "/Other/9", "9"); + + REQUIRE(svc.steam->updatableIds({b, b, other}) == QStringList{"222"}); +} + +TEST_CASE("result changed excludes up to date items", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + auto result = runLoop(svc.steam->downloadMods({"111"})); + + REQUIRE(result.has_value()); + REQUIRE(result->changed == QStringList{"111"}); + REQUIRE(svc.steam->activeIds().isEmpty()); +} + +TEST_CASE("batch changed ignores zero byte items", "[downloads][steam]") { + SteamBatch batch(2); + batch.succeeded = {"1", "2"}; + batch.bytes.insert("1", {0, 0}); + batch.bytes.insert("2", {5, 5}); + REQUIRE(batch.changed() == QStringList{"2"}); +} + +TEST_CASE("success records sync time and persists", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + client->errors.insert(222, QStringLiteral("gone")); + Service svc({client}); + + REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); + REQUIRE(syncedOf(svc, "111").has_value()); + REQUIRE_FALSE(syncedOf(svc, "222").has_value()); + + auto reloaded = + svc.config.load(px::modrim::kWorkshopSyncFile); + REQUIRE(reloaded.has_value()); + std::vector keys; + for (const auto& [k, _] : reloaded->synced) { + keys.push_back(k); + } + REQUIRE(keys == std::vector{"111"}); +} + +TEST_CASE("stale ids never synced first then oldest", "[downloads][steam]") { + TestApplication app; + Service svc({}); + for (const char* name : {"1", "2", "3", "4"}) { + svc.mods.push_back(makeModAt(svc.localPath + "/" + name, name)); + } + WorkshopSyncState seed; + seed.synced = {{"1", 1000.0}, {"2", 9000.0}, {"3", 500.0}}; + REQUIRE(svc.config.save(px::modrim::kWorkshopSyncFile, seed).has_value()); + runLoopVoid(svc.steam->loadSyncState()); + + REQUIRE(svc.steam->staleIds(3600.0, 10000.0) == QStringList{"4", "3", "1"}); +} + +TEST_CASE("auto update only syncs stale mods when enabled", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + svc.mods.push_back(makeModAt(svc.localPath + "/1", "1")); + + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE_FALSE(syncedOf(svc, "1").has_value()); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 6, + .parallel_items = 8, + .threads_per_item = 1, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE(syncedOf(svc, "1").has_value()); + const double firstSynced = *syncedOf(svc, "1"); + + runLoopVoid(svc.steam->autoUpdateOnce()); + REQUIRE(*syncedOf(svc, "1") == firstSynced); +} + +TEST_CASE("titles from steam are emitted", "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + QHash titles; + svc.steam->downloadItemTitled.connect( + [&](const auto& t) { titles.insert(t.modId, t.title); }); + std::vector phases; + svc.steam->downloadPhaseChanged.connect( + [&](const auto& p) { phases.push_back(p); }); + + REQUIRE(runLoop(svc.steam->downloadMods({"111", "222"})).has_value()); + + REQUIRE(phases == std::vector{"login", "query", "run"}); + REQUIRE(titles.value("111") == "Title 111"); + REQUIRE(titles.value("222") == "Title 222"); +} + +TEST_CASE("client is relogged after download raises", "[downloads][steam]") { + TestApplication app; + auto dying = std::make_shared(); + auto fresh = std::make_shared(); + Service svc({dying, fresh}); + + auto first = runLoop(svc.steam->downloadMods({"1"})); + REQUIRE(first.has_value()); + REQUIRE(first->failed == QStringList{"1"}); + + auto second = runLoop(svc.steam->downloadMods({"2"})); + REQUIRE(second.has_value()); + REQUIRE(second->succeeded == QStringList{"2"}); + REQUIRE(svc.logins.size() == 2); +} + +TEST_CASE("download uses configured parallelism clamped", + "[downloads][steam]") { + TestApplication app; + auto client = std::make_shared(); + Service svc({client}); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 3, + .threads_per_item = 5, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + REQUIRE(runLoop(svc.steam->downloadMods({"1"})).has_value()); + + svc.steam->settings().update(SteamSettings{ + .auto_update_hours = 0, + .parallel_items = 0, + .threads_per_item = 999, + .proxy = {}, + .connect_timeout = 10, + .stall_timeout = 30, + }); + REQUIRE(runLoop(svc.steam->downloadMods({"2"})).has_value()); + + REQUIRE(client->calls.size() == 2); + REQUIRE(client->calls[0].workers == 15); + REQUIRE(client->calls[1].workers == 16); +} + +#endif // PX_HAS_STEAM diff --git a/tests/core/models/metadata/test_parsing.cpp b/tests/core/models/metadata/test_parsing.cpp new file mode 100644 index 0000000..205c5cf --- /dev/null +++ b/tests/core/models/metadata/test_parsing.cpp @@ -0,0 +1,301 @@ +#include +#include +#include + +#include +#include + +#include "core/models/metadata/parsing.hpp" +#include "core/models/metadata/structures.hpp" + +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveStr; +using px::modrim::createAboutModFromElement; +using px::modrim::createListedModFromPath; +using px::modrim::ListedMod; + +namespace { + +// Parses a `` document and builds the mod from its root element. +std::shared_ptr aboutFromXml(const char* xml, + const char* targetVersion = "1.5") { + pugi::xml_document doc; + REQUIRE(doc.load_string(xml)); + return createAboutModFromElement(doc.document_element(), + QString::fromUtf8(targetVersion)); +} + +// Writes `content` as About/About.xml under a fresh mod directory inside +// `root`. +QString writeAboutXml(const QString& root, const QByteArray& content) { + REQUIRE(QDir(root).mkpath(QStringLiteral("About"))); + QFile file(QDir(root).filePath(QStringLiteral("About/About.xml"))); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(content); + return root; +} + +} // namespace + +TEST_CASE("basic About.xml fields are parsed") { + const auto mod = aboutFromXml(R"( + Test Mod + Tester + test.mod + A test mod. +
  • 1.5
  • +
    )"); + + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Test Mod")); + CHECK(mod->authors == QStringList{QStringLiteral("Tester")}); + CHECK(mod->packageId.str() == QStringLiteral("test.mod")); + CHECK(mod->description == QStringLiteral("A test mod.")); + CHECK(mod->supportedVersions == QSet{QStringLiteral("1.5")}); +} + +TEST_CASE("About.xml without packageId is invalid") { + const auto mod = + aboutFromXml("No ID Mod"); + CHECK_FALSE(mod->valid); + CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); +} + +TEST_CASE("About.xml with empty packageId is invalid") { + const auto mod = + aboutFromXml(""); + CHECK_FALSE(mod->valid); + CHECK(mod->packageId.str() == QStringLiteral("missing.packageid")); +} + +TEST_CASE("versioned entries must match the version key exactly") { + const auto wrong = aboutFromXml(R"(x.y + +
  • wrong
  • +
  • wrong
  • +
    + +
  • wrong.dep
  • +
    +
    )"); + CHECK(wrong->aboutRules.loadAfter.size() == 0); + CHECK(wrong->aboutRules.dependencies.isEmpty()); + + const auto right = aboutFromXml(R"(x.y +
  • right
  • +
    )"); + CHECK(right->aboutRules.loadAfter.contains(QStringLiteral("right"))); +} + +TEST_CASE("versioned dependencies replace the unversioned list") { + const auto mod = aboutFromXml(R"(x.y +
  • plain.dep
  • + +
  • versioned.dep
  • +
    +
    )"); + + REQUIRE(mod->aboutRules.dependencies.size() == 1); + CHECK(mod->aboutRules.dependencies.first().packageId == + CaseInsensitiveStr(QStringLiteral("versioned.dep"))); +} + +TEST_CASE("preferVersioned=false ignores versioned entries") { + pugi::xml_document doc; + REQUIRE(doc.load_string(R"(x.y + +
  • versioned.dep
  • +
    +
    )")); + const auto mod = + createAboutModFromElement(doc.document_element(), QStringLiteral("1.5"), + /*preferVersioned=*/false); + CHECK(mod->aboutRules.dependencies.isEmpty()); +} + +TEST_CASE( + "dependencies keep declaration order, skip duplicates and null entries") { + const auto mod = aboutFromXml(R"(x.y + +
  • Dep.OneDep One + https://example.test/one +
  • alt.one
  • + +
  • skipped.null
  • +
  • dep.oneDuplicate
  • +
  • Dep.Two
  • +
    +
    )"); + + const auto& deps = mod->aboutRules.dependencies; + REQUIRE(deps.size() == 2); + CHECK(deps[0].packageId == CaseInsensitiveStr(QStringLiteral("dep.one"))); + CHECK(deps[0].name == QStringLiteral("Dep One")); + CHECK(deps[0].workshopUrl == QStringLiteral("https://example.test/one")); + CHECK(deps[0].alternativePackageIds.contains(QStringLiteral("alt.one"))); + CHECK(deps[1].packageId == CaseInsensitiveStr(QStringLiteral("dep.two"))); +} + +TEST_CASE("force-load entries are merged into the load-order sets") { + const auto mod = aboutFromXml(R"(x.y +
  • before.a
  • +
  • before.b
  • +
  • after.a
  • +
  • after.b
  • +
  • bad.one
  • +
    )"); + + CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.a"))); + CHECK(mod->aboutRules.loadBefore.contains(QStringLiteral("before.b"))); + CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.a"))); + CHECK(mod->aboutRules.loadAfter.contains(QStringLiteral("after.b"))); + CHECK(mod->aboutRules.incompatibleWith.contains(QStringLiteral("bad.one"))); +} + +TEST_CASE( + "descriptionsByVersion selects the description for the target version") { + const auto mod = aboutFromXml(R"(x.y + Old textText for 1.5 + )"); + CHECK(mod->description == QStringLiteral("Text for 1.5")); +} + +TEST_CASE("a parsed mod without a description does not claim to be invalid") { + const auto mod = aboutFromXml( + "x.yValid"); + CHECK(mod->valid); + CHECK(mod->description.isEmpty()); +} + +TEST_CASE("explicit steamAppId is read") { + const auto mod = aboutFromXml(R"(x.y + 1234)"); + CHECK(mod->steamAppId == 1234); +} + +TEST_CASE( + "DLC package ids depend on the RimWorld base game and take the DLC name") { + const auto mod = aboutFromXml( + "ludeon.rimworld.royalty"); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Royalty")); + + const auto* base = mod->aboutRules.findDependency( + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))); + REQUIRE(base != nullptr); + CHECK(base->name == QStringLiteral("RimWorld")); +} + +TEST_CASE("official mods without a name use the package id mapping") { + struct Case { + const char* packageId; + const char* expectedName; + }; + const Case cases[] = { + {"Ludeon.RimWorld", "Core"}, + {"Ludeon.RimWorld.Royalty", "Royalty"}, + {"Ludeon.RimWorld.Ideology", "Ideology"}, + {"Ludeon.RimWorld.Biotech", "Biotech"}, + {"Ludeon.RimWorld.Anomaly", "Anomaly"}, + {"Ludeon.RimWorld.Odyssey", "Odyssey"}, + }; + QTemporaryDir root; + REQUIRE(root.isValid()); + + for (const Case& test : cases) { + const QString modDir = root.filePath(QString::fromUtf8(test.packageId)); + writeAboutXml( + modDir, QStringLiteral("%1" + "Ludeon Studios") + .arg(QString::fromUtf8(test.packageId)) + .toUtf8()); + + const auto result = createListedModFromPath(modDir, QStringLiteral("1.6")); + REQUIRE(result.has_value()); + CHECK((*result)->valid); + CHECK((*result)->name == QString::fromUtf8(test.expectedName)); + } +} + +TEST_CASE("explicit name wins for official mods") { + const auto mod = aboutFromXml(R"( + ludeon.rimworld.royalty + Custom official name)", + "1.6"); + CHECK(mod->valid); + CHECK(mod->name == QStringLiteral("Custom official name")); +} + +TEST_CASE("createListedModFromPath parses a valid mod directory") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modPath = + writeAboutXml(root.path(), R"( + + Test Mod + TestAuthor + test.mod + A test mod for unit testing. +
  • 1.5
  • + 1.0.0 +
  • ludeon.rimworld
  • +
    +)"); + + const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); + REQUIRE(result.has_value()); + const auto& mod = *result; + CHECK(mod->valid); + const auto* about = dynamic_cast(mod.get()); + REQUIRE(about != nullptr); + CHECK(about->name == QStringLiteral("Test Mod")); + CHECK(about->authors == QStringList{QStringLiteral("TestAuthor")}); + CHECK(about->packageId.str() == QStringLiteral("test.mod")); + CHECK(about->modVersion == QStringLiteral("1.0.0")); + CHECK( + about->aboutRules.loadAfter.contains(QStringLiteral("ludeon.rimworld"))); + CHECK(mod->modPath() == modPath); + CHECK(mod->providerId.isEmpty()); + CHECK(about->mtime > 0.0); +} + +TEST_CASE("createListedModFromPath rejects non-directory paths") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString file = writeAboutXml(root.path(), ""); + + const auto result = createListedModFromPath( + QDir(file).filePath(QStringLiteral("About/About.xml")), + QStringLiteral("1.5")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.startsWith( + QStringLiteral("Path must be a directory"))); +} + +TEST_CASE( + "malformed About.xml yields an invalid AboutXmlMod with the mod path") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modPath = writeAboutXml(root.path(), ""); + + const auto result = createListedModFromPath(modPath, QStringLiteral("1.5")); + REQUIRE(result.has_value()); + const auto& mod = *result; + CHECK_FALSE(mod->valid); + CHECK(dynamic_cast(mod.get()) != nullptr); + CHECK(mod->modPath() == modPath); + CHECK(mod->uuid() == modPath); +} + +TEST_CASE("directory without About.xml yields an invalid plain ListedMod") { + QTemporaryDir root; + REQUIRE(root.isValid()); + + const auto result = + createListedModFromPath(root.path(), QStringLiteral("1.5")); + REQUIRE(result.has_value()); + CHECK_FALSE((*result)->valid); + CHECK(dynamic_cast(result->get()) == nullptr); +} diff --git a/tests/core/models/metadata/test_schemas.cpp b/tests/core/models/metadata/test_schemas.cpp new file mode 100644 index 0000000..625c5f8 --- /dev/null +++ b/tests/core/models/metadata/test_schemas.cpp @@ -0,0 +1,87 @@ +#include + +#include +#include + +#include "core/models/metadata/structures.hpp" + +using namespace px::modrim; + +TEST_CASE("CaseInsensitiveStr serializes as a lowercased JSON string") { + const CaseInsensitiveStr pid(QStringLiteral("Ludeon.RimWorld")); + const auto written = glz::write_json(pid); + REQUIRE(written.has_value()); + CHECK(*written == R"("ludeon.rimworld")"); + + CaseInsensitiveStr parsed; + REQUIRE_FALSE(glz::read_json(parsed, R"("Test.Mod")")); + CHECK(parsed == CaseInsensitiveStr(QStringLiteral("test.mod"))); +} + +TEST_CASE("community rules schema accepts single-value and list comments") { + const std::string json = R"({ + "timestamp": 42, + "rules": { + "pkg.a": { + "loadAfter": { + "other": {"name": "Other", "comment": ["first", "second"]} + }, + "loadTop": {"value": true, "comment": "top"} + } + } + })"; + + ExternalRulesSchema schema; + const auto error = glz::read_json(schema, json); + REQUIRE_FALSE(error); + CHECK(schema.timestamp == 42); + + const ExternalRule& rule = schema.rules.at("pkg.a"); + CHECK(rule.loadTop.value); + CHECK(std::get(rule.loadTop.comment) == "top"); + + const SubExternalRule& after = rule.loadAfter.at("other"); + CHECK(std::get(after.name) == "Other"); + CHECK(std::get>(after.comment) == + std::vector{"first", "second"}); + CHECK(rule.loadBottom.value == false); +} + +TEST_CASE("steam workshop database accepts null, list, and object unions") { + const std::string json = R"({ + "version": 3, + "database": { + "123": { + "packageId": "author.mod", + "gameVersions": null, + "authors": "Solo Author", + "dependencies": { + "dep.list": ["alt.a"], + "dep.object": {"name": "Dep", "url": "https://example.test"} + }, + "tags": [{"key": "value"}], + "blacklist": {"value": true, "comment": "outdated"} + } + } + })"; + + SteamDbSchema schema; + const auto error = glz::read_json(schema, json); + REQUIRE_FALSE(error); + CHECK(schema.version == 3); + + const SteamDbEntry& entry = schema.database.at("123"); + CHECK(entry.packageId == "author.mod"); + CHECK(std::holds_alternative(entry.gameVersions)); + CHECK(std::get(entry.authors) == "Solo Author"); + CHECK(std::get>(entry.dependencies.at("dep.list")) == + std::vector{"alt.a"}); + const auto& object = + std::get(entry.dependencies.at("dep.object")); + CHECK(object.name == "Dep"); + CHECK(object.url == "https://example.test"); + REQUIRE(entry.tags.size() == 1); + CHECK(entry.tags.front().at("key") == "value"); + CHECK(entry.blacklist.value); + CHECK(entry.blacklist.comment == "outdated"); +} diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp new file mode 100644 index 0000000..6f26b7e --- /dev/null +++ b/tests/core/models/metadata/test_structures.cpp @@ -0,0 +1,175 @@ +#include +#include + +#include + +#include "core/models/metadata/structures.hpp" + +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveSet; +using px::modrim::CaseInsensitiveStr; +using px::modrim::ListedMod; +using px::modrim::ModsConfig; + +TEST_CASE("CaseInsensitiveStr stores the lowercased package id") { + const CaseInsensitiveStr pid(QStringLiteral("Test.Mod.PackageId")); + CHECK(pid.str() == QStringLiteral("test.mod.packageid")); +} + +TEST_CASE("CaseInsensitiveStr compares ignoring case") { + const CaseInsensitiveStr a(QStringLiteral("Test.Mod")); + const CaseInsensitiveStr b(QStringLiteral("test.mod")); + CHECK(a == b); +} + +TEST_CASE("CaseInsensitiveStr finds hash entries regardless of case") { + QHash map; + map.insert(CaseInsensitiveStr(QStringLiteral("Test.Mod")), + QStringLiteral("value")); + CHECK(map.value(CaseInsensitiveStr(QStringLiteral("test.mod"))) == + QStringLiteral("value")); +} + +TEST_CASE("CaseInsensitiveSet membership ignores case") { + CaseInsensitiveSet set; + set.add(QStringLiteral("Test.Mod")); + CHECK(set.contains(QStringLiteral("test.mod"))); + CHECK(set.contains(QStringLiteral("Test.Mod"))); +} + +TEST_CASE("CaseInsensitiveSet discard removes regardless of case") { + CaseInsensitiveSet set(QStringList{QStringLiteral("Test.Mod")}); + set.discard(QStringLiteral("test.mod")); + CHECK(set.size() == 0); +} + +TEST_CASE("CaseInsensitiveSet union keeps elements of both operands") { + const CaseInsensitiveSet a(QStringList{QStringLiteral("mod.a")}); + const CaseInsensitiveSet b(QStringList{QStringLiteral("Mod.B")}); + const CaseInsensitiveSet c = a | b; + CHECK(c.contains(QStringLiteral("mod.a"))); + CHECK(c.contains(QStringLiteral("mod.b"))); +} + +TEST_CASE("empty CaseInsensitiveSets compare equal") { + CHECK(CaseInsensitiveSet() == CaseInsensitiveSet()); +} + +TEST_CASE("ListedMod has the invalid-mod defaults") { + const ListedMod mod; + CHECK(mod.name == QStringLiteral("Unknown Mod Name")); + CHECK(mod.valid); + CHECK(mod.providerId.isEmpty()); + CHECK_FALSE(mod.modPath().has_value()); +} + +TEST_CASE("ListedMod provider_id is assignable") { + ListedMod mod; + mod.providerId = QStringLiteral("local"); + CHECK(mod.providerId == QStringLiteral("local")); +} + +TEST_CASE("pathless ListedMod uuids are unique") { + const ListedMod a; + const ListedMod b; + CHECK(a.uuid() != b.uuid()); +} + +TEST_CASE("ListedMod mod_path can be set only once") { + ListedMod mod; + REQUIRE(mod.setModPath(QStringLiteral("/mods/a"))); + CHECK(mod.uuid() == QStringLiteral("/mods/a")); + + const auto second = mod.setModPath(QStringLiteral("/mods/b")); + REQUIRE_FALSE(second); + CHECK(second.error().message == + QStringLiteral("Mod path already set. Cannot override.")); + CHECK(mod.modPath() == QStringLiteral("/mods/a")); +} + +TEST_CASE("AboutXmlMod has the About.xml defaults") { + const AboutXmlMod mod; + CHECK(mod.name == QStringLiteral("Unknown Mod Name")); + CHECK(mod.authors.isEmpty()); + CHECK(mod.modVersion.isEmpty()); + CHECK(mod.packageId == CaseInsensitiveStr(QStringLiteral("invalid.mod"))); +} + +TEST_CASE("AboutXmlMod reports the DLC name for DLC package ids") { + AboutXmlMod mod; + mod.packageId = CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")); + REQUIRE(mod.getDlcName().has_value()); + CHECK(*mod.getDlcName() == QStringLiteral("RimWorld - Royalty")); +} + +TEST_CASE("ModsConfig normalizes package ids to lowercase") { + const ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("Test.UPPERCASE.Mod"))}, + .knownExpansions = {}, + }; + CHECK(config.activeMods.first() == + CaseInsensitiveStr(QStringLiteral("test.uppercase.mod"))); +} + +TEST_CASE("ModsConfig clear_active_mods keeps expansions") { + ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, + .knownExpansions = {CaseInsensitiveStr( + QStringLiteral("ludeon.rimworld.royalty"))}, + }; + config.clearActiveMods(); + CHECK(config.activeMods.isEmpty()); + CHECK(config.knownExpansions.size() == 1); +} + +TEST_CASE("ModsConfig clear_all empties both lists") { + ModsConfig config{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld"))}, + .knownExpansions = {CaseInsensitiveStr( + QStringLiteral("ludeon.rimworld.royalty"))}, + }; + config.clearAll(); + CHECK(config.activeMods.isEmpty()); + CHECK(config.knownExpansions.isEmpty()); +} + +TEST_CASE("ModsConfig detects duplicate active mods ignoring case") { + const ModsConfig duplicated{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), + CaseInsensitiveStr(QStringLiteral("Mod.A"))}, + .knownExpansions = {}, + }; + CHECK(duplicated.checkActiveDuplicates()); + + const ModsConfig distinct{ + .version = QStringLiteral("1.5"), + .activeMods = {CaseInsensitiveStr(QStringLiteral("mod.a")), + CaseInsensitiveStr(QStringLiteral("mod.b"))}, + .knownExpansions = {}, + }; + CHECK_FALSE(distinct.checkActiveDuplicates()); +} + +TEST_CASE("ModsConfig detects duplicate expansions ignoring case") { + const ModsConfig duplicated{ + .version = QStringLiteral("1.5"), + .activeMods = {}, + .knownExpansions = + {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), + CaseInsensitiveStr(QStringLiteral("Ludeon.RimWorld.Royalty"))}, + }; + CHECK(duplicated.checkExpansionsDuplicates()); + + const ModsConfig distinct{ + .version = QStringLiteral("1.5"), + .activeMods = {}, + .knownExpansions = + {CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.royalty")), + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld.ideology"))}, + }; + CHECK_FALSE(distinct.checkExpansionsDuplicates()); +} diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp new file mode 100644 index 0000000..ff1841d --- /dev/null +++ b/tests/core/organizer/test_db.cpp @@ -0,0 +1,390 @@ +#include +#include +#include + +#include + +#include "core/organizer/db.hpp" +#include "core/organizer/defaults.hpp" +#include "core/organizer/models.hpp" +#include "test_util.hpp" + +using namespace px::modrim::organizer; +using organizer_test::expect; +using organizer_test::expectError; +using organizer_test::wait; +using test_support::TestApplication; + +namespace { + +QString organizerFile(const QTemporaryDir& dir) { + return dir.filePath(QStringLiteral("organizer.db")); +} + +} // namespace + +TEST_CASE("a fresh database holds only the root folder") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const OrganizerState state = expect(wait(db.load())); + REQUIRE(state.folders.size() == 1); + const Folder root = state.folders.value(kRootId); + CHECK(root.id == kRootId); + CHECK_FALSE(root.parentId.has_value()); + CHECK(root.name == QStringLiteral("Root")); + CHECK_FALSE(root.collapsed); + CHECK(state.placements.isEmpty()); + CHECK(state.tags.isEmpty()); + CHECK(state.modTags.isEmpty()); + CHECK(state.rules.isEmpty()); + + expectError(wait(db.deleteFolder(kRootId))); + expectError(wait(db.renameFolder(kRootId, QStringLiteral("CustomRoot")))); + expectError(wait(db.setParent(kRootId, 2))); +} + +TEST_CASE("sibling names are unique case-insensitively, per parent") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder fA = + expect(wait(db.createFolder(kRootId, QStringLiteral("FolderA")))); + CHECK(fA.id > kRootId); + CHECK(fA.parentId == kRootId); + CHECK(fA.name == QStringLiteral("FolderA")); + + expectError(wait(db.createFolder(kRootId, QStringLiteral("foldera")))); + + const Folder fB = + expect(wait(db.createFolder(kRootId, QStringLiteral("FolderB")))); + expectError(wait(db.renameFolder(fB.id, QStringLiteral("foldera")))); + + // The same name is fine under a different parent. + const Folder fSub = + expect(wait(db.createFolder(fA.id, QStringLiteral("FolderB")))); + CHECK(fSub.parentId == fA.id); + CHECK(fSub.name == QStringLiteral("FolderB")); + + expectError(wait(db.setParent(fSub.id, kRootId))); +} + +TEST_CASE( + "placing lowercases package ids and upserts; unplacing removes rows") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder f1 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Folder1")))); + const Folder f2 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Folder2")))); + + expect(wait(db.place({}, 9999))); + expect(wait(db.place( + {QStringLiteral("Package.One"), QStringLiteral("PACKAGE.TWO")}, f1.id))); + OrganizerState st = expect(wait(db.load())); + CHECK(st.placements.value(QStringLiteral("package.one")) == f1.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + // Upsert: moving an existing placement. + expect(wait(db.place({QStringLiteral("package.ONE")}, f2.id))); + st = expect(wait(db.load())); + CHECK(st.placements.value(QStringLiteral("package.one")) == f2.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + expect(wait(db.unplace({QStringLiteral("PACKAGE.ONE")}))); + st = expect(wait(db.load())); + CHECK_FALSE(st.placements.contains(QStringLiteral("package.one"))); + CHECK(st.placements.value(QStringLiteral("package.two")) == f1.id); + + expectError(wait(db.place({QStringLiteral("package.three")}, 9999))); +} + +TEST_CASE( + "create_folder_with places its mods and rolls back on a duplicate name") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder f = expect(wait(db.createFolderWith( + kRootId, QStringLiteral("CoreMods"), + {QStringLiteral("Package.One"), QStringLiteral("package.two"), + QStringLiteral("PACKAGE.THREE")}))); + CHECK(f.id > kRootId); + CHECK(f.name == QStringLiteral("CoreMods")); + CHECK(f.parentId == kRootId); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.folders.contains(f.id)); + CHECK(st.placements.value(QStringLiteral("package.one")) == f.id); + CHECK(st.placements.value(QStringLiteral("package.two")) == f.id); + CHECK(st.placements.value(QStringLiteral("package.three")) == f.id); + + expectError(wait(db.createFolderWith( + kRootId, QStringLiteral("coremods"), + {QStringLiteral("package.four"), QStringLiteral("package.five")}))); + + const OrganizerState after = expect(wait(db.load())); + CHECK_FALSE(after.placements.contains(QStringLiteral("package.four"))); + CHECK_FALSE(after.placements.contains(QStringLiteral("package.five"))); + CHECK(after.folders.size() == st.folders.size()); +} + +TEST_CASE( + "deleting a folder re-homes its subtree's placements and drops its " + "descendants") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder fParent = + expect(wait(db.createFolder(kRootId, QStringLiteral("Parent")))); + const Folder fChild = + expect(wait(db.createFolder(fParent.id, QStringLiteral("Child")))); + const Folder fGrandchild = + expect(wait(db.createFolder(fChild.id, QStringLiteral("Grandchild")))); + const Folder fOther = + expect(wait(db.createFolder(kRootId, QStringLiteral("Other")))); + + expect(wait(db.place({QStringLiteral("mod.parent")}, fParent.id))); + expect(wait(db.place({QStringLiteral("mod.child")}, fChild.id))); + expect(wait(db.place({QStringLiteral("mod.grandchild")}, fGrandchild.id))); + expect(wait(db.place({QStringLiteral("mod.other")}, fOther.id))); + expect(wait(db.place({QStringLiteral("mod.root")}, kRootId))); + + expect(wait(db.deleteFolder(fParent.id))); + + const OrganizerState st = expect(wait(db.load())); + CHECK_FALSE(st.folders.contains(fParent.id)); + CHECK_FALSE(st.folders.contains(fChild.id)); + CHECK_FALSE(st.folders.contains(fGrandchild.id)); + CHECK(st.folders.contains(fOther.id)); + + CHECK(st.placements.value(QStringLiteral("mod.parent")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.child")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.grandchild")) == kRootId); + CHECK(st.placements.value(QStringLiteral("mod.other")) == fOther.id); + CHECK(st.placements.value(QStringLiteral("mod.root")) == kRootId); + + expectError(wait(db.deleteFolder(9999))); +} + +TEST_CASE( + "tags: create, rename, unique names, mod tagging and cascade on delete") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Tag t1 = expect( + wait(db.createTag(QStringLiteral("Tag1"), QStringLiteral("#ff0000")))); + const Tag t2 = expect( + wait(db.createTag(QStringLiteral("Tag2"), QStringLiteral("#00ff00")))); + CHECK(t1.name == QStringLiteral("Tag1")); + CHECK(t1.color == QStringLiteral("#ff0000")); + + expectError( + wait(db.createTag(QStringLiteral("tag1"), QStringLiteral("#123456")))); + + expect(wait( + db.setModTags({QStringLiteral("Mod.Alpha"), QStringLiteral("Mod.Beta")}, + {t1.id, t2.id}, {}))); + OrganizerState st = expect(wait(db.load())); + CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == + QSet{t1.id, t2.id}); + CHECK(st.modTags.value(QStringLiteral("mod.beta")) == + QSet{t1.id, t2.id}); + + expect(wait(db.setModTags({QStringLiteral("mod.alpha")}, {}, {t1.id}))); + st = expect(wait(db.load())); + CHECK(st.modTags.value(QStringLiteral("mod.alpha")) == QSet{t2.id}); + + expectError(wait(db.setModTags({QStringLiteral("mod.alpha")}, {9999}, {}))); + + expect(wait(db.updateTag(t1.id, QStringLiteral("Tag1Updated"), + QStringLiteral("#aaaaaa")))); + st = expect(wait(db.load())); + CHECK(st.tags.value(t1.id).name == QStringLiteral("Tag1Updated")); + + expectError(wait( + db.updateTag(t1.id, QStringLiteral("tag2"), QStringLiteral("#aaaaaa")))); + + expect(wait(db.deleteTag(t2.id))); + st = expect(wait(db.load())); + CHECK_FALSE(st.tags.contains(t2.id)); + CHECK((!st.modTags.contains(QStringLiteral("mod.alpha")) || + st.modTags.value(QStringLiteral("mod.alpha")).isEmpty())); + CHECK(st.modTags.value(QStringLiteral("mod.beta")) == QSet{t1.id}); + + expectError(wait(db.deleteTag(9999))); +} + +TEST_CASE( + "replacing rules numbers them by position and deleting a folder drops its " + "rules") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder f1 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Target1")))); + const Folder f2 = + expect(wait(db.createFolder(kRootId, QStringLiteral("Target2")))); + + const QList specs{ + RuleSpec{.field = RuleField::kName, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("Vanilla"), + .folderId = f1.id}, + RuleSpec{.field = RuleField::kAuthor, + .op = RuleOp::kContains, + .pattern = QStringLiteral("Oskar"), + .folderId = f2.id}, + RuleSpec{.field = RuleField::kPackageId, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("ludeon.rimworld"), + .folderId = f1.id}, + }; + const QList rules = expect(wait(db.replaceRules(specs))); + REQUIRE(rules.size() == 3); + CHECK(rules[0].position == 0); + CHECK(rules[1].position == 1); + CHECK(rules[2].position == 2); + CHECK(rules[0].field == RuleField::kName); + CHECK(rules[1].field == RuleField::kAuthor); + CHECK(rules[2].field == RuleField::kPackageId); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.rules == rules); + + // A rule pointing at a missing folder fails the whole replacement. + expectError(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("x"), + .folderId = 9999}}))); + + // Deleting Target1 drops its rules; the rule for Target2 remains. + expect(wait(db.deleteFolder(f1.id))); + const OrganizerState after = expect(wait(db.load())); + REQUIRE(after.rules.size() == 1); + CHECK(after.rules[0].id == rules[1].id); + CHECK(after.rules[0].folderId == f2.id); +} + +TEST_CASE("state survives closing and reopening the database") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString file = organizerFile(dir); + + Folder folder; + Tag tag; + { + OrganizerDb db1(file); + folder = expect( + wait(db1.createFolder(kRootId, QStringLiteral("PersistFolder")))); + expect(wait(db1.setCollapsed(folder.id, true))); + expect(wait(db1.place({QStringLiteral("persist.pkg")}, folder.id))); + tag = expect(wait(db1.createTag(QStringLiteral("PersistTag"), + QStringLiteral("#abcdef")))); + expect(wait(db1.setModTags({QStringLiteral("persist.pkg")}, {tag.id}, {}))); + expect(wait(db1.replaceRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("mod"), + .folderId = folder.id}}))); + wait(db1.close()); + } + + OrganizerDb db2(file); + const OrganizerState st = expect(wait(db2.load())); + REQUIRE(st.folders.contains(folder.id)); + CHECK(st.folders.value(folder.id).name == QStringLiteral("PersistFolder")); + CHECK(st.folders.value(folder.id).collapsed); + CHECK(st.placements.value(QStringLiteral("persist.pkg")) == folder.id); + REQUIRE(st.tags.contains(tag.id)); + CHECK(st.tags.value(tag.id).name == QStringLiteral("PersistTag")); + CHECK(st.modTags.value(QStringLiteral("persist.pkg")) == + QSet{tag.id}); + REQUIRE(st.rules.size() == 1); + CHECK(st.rules[0].pattern == QStringLiteral("mod")); + CHECK(st.rules[0].folderId == folder.id); +} + +TEST_CASE( + "append_named_rules is atomic and resolves folders case-insensitively") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + OrganizerDb db(organizerFile(dir)); + + const Folder existing = + expect(wait(db.createFolder(kRootId, QStringLiteral("Existing")))); + expect(wait(db.replaceRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("a"), + .folderId = existing.id}}))); + const OrganizerState before = expect(wait(db.load())); + + expectError(wait(db.appendNamedRules( + kRootId, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("New"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("Missing"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("c")}})), + QStringLiteral("Missing")); + CHECK(expect(wait(db.load())) == before); + + expect(wait( + db.appendNamedRules(kRootId, {QStringLiteral("New")}, + {StandardRule{.folder = QStringLiteral("existing"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("b")}, + StandardRule{.folder = QStringLiteral("NEW"), + .field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("c")}}))); + + const OrganizerState st = expect(wait(db.load())); + qint64 newId = -1; + for (const Folder& folder : st.folders) { + if (folder.name == QStringLiteral("New")) { + newId = folder.id; + } + } + REQUIRE(st.rules.size() == 3); + CHECK(st.rules[0].position == 0); + CHECK(st.rules[0].pattern == QStringLiteral("a")); + CHECK(st.rules[0].folderId == existing.id); + CHECK(st.rules[1].position == 1); + CHECK(st.rules[1].pattern == QStringLiteral("b")); + CHECK(st.rules[1].folderId == existing.id); + CHECK(st.rules[2].position == 2); + CHECK(st.rules[2].pattern == QStringLiteral("c")); + CHECK(st.rules[2].folderId == newId); +} + +TEST_CASE("the database file is created in a missing directory on first use") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString file = + dir.filePath(QStringLiteral("nested/config/organizer.db")); + OrganizerDb db(file); + + expect(wait(db.createFolder(kRootId, QStringLiteral("Made")))); + CHECK(QFile::exists(file)); +} diff --git a/tests/core/organizer/test_resolve.cpp b/tests/core/organizer/test_resolve.cpp new file mode 100644 index 0000000..4c8de6e --- /dev/null +++ b/tests/core/organizer/test_resolve.cpp @@ -0,0 +1,661 @@ +#include +#include + +#include + +#include "core/models/metadata/structures.hpp" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" + +using namespace px::modrim; +using namespace px::modrim::organizer; + +namespace { + +ListedModPtr makeMod([[maybe_unused]] const QString& uuid, const QString& name, + const std::optional& pid = std::nullopt, + const QString& author = {}, + const QString& provider = QStringLiteral("local")) { + if (!pid) { + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = provider; + return mod; + } + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = provider; + mod->packageId = CaseInsensitiveStr(QStringView(*pid)); + if (!author.isEmpty()) { + mod->authors.append(author); + } + return mod; +} + +Folder folder(qint64 id, std::optional parentId, QString name) { + Folder f; + f.id = id; + f.parentId = parentId; + f.name = std::move(name); + f.collapsed = false; + return f; +} + +struct RuleSeed { + RuleField field; + RuleOp op; + QString pattern; + qint64 folderId; +}; + +OrganizerState makeState(QList folders = {}, + QHash placements = {}, + QList rules = {}, + QHash> modTags = {}, + QList tags = {}) { + OrganizerState state; + state.folders.insert(kRootId, + folder(kRootId, std::nullopt, QStringLiteral(""))); + for (const Folder& f : folders) { + state.folders.insert(f.id, f); + } + state.placements = std::move(placements); + state.modTags = std::move(modTags); + for (const Tag& tag : tags) { + state.tags.insert(tag.id, tag); + } + for (qsizetype i = 0; i < rules.size(); ++i) { + const auto& [fld, op, pat, fid] = rules[i]; + state.rules.append(Rule{.id = static_cast(i + 1), + .position = static_cast(i), + .field = fld, + .op = op, + .pattern = pat, + .folderId = fid}); + } + return state; +} + +const FolderNode* findNode(const FolderNode& root, qint64 folderId) { + for (const FolderNode* node : root.walk()) { + if (node->folder.id == folderId) { + return node; + } + } + return nullptr; +} + +} // namespace + +TEST_CASE( + "preview_rule_matches counts first matches and assignable ungrouped mods") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), + folder(3, kRootId, QStringLiteral("B"))}, + {{QStringLiteral("manual.mod"), 3}, + {QStringLiteral("root.mod"), kRootId}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("old."), 2}}); + + const ModsByUuid mods{ + {QStringLiteral("a"), + makeMod(QStringLiteral("a"), QStringLiteral("Core Alpha"), + QStringLiteral("new.alpha"), QStringLiteral("Author"))}, + {QStringLiteral("b"), + makeMod(QStringLiteral("b"), QStringLiteral("Core Beta"), + QStringLiteral("old.beta"), QStringLiteral("Other"))}, + {QStringLiteral("c"), + makeMod(QStringLiteral("c"), QStringLiteral("Core Manual"), + QStringLiteral("manual.mod"), QStringLiteral("Author"))}, + {QStringLiteral("d"), + makeMod(QStringLiteral("d"), QStringLiteral("Core Ungrouped"), + QStringLiteral("root.mod"), QStringLiteral("Author"))}, + {QStringLiteral("e"), + makeMod(QStringLiteral("e"), QStringLiteral("Core No ID"))}, + }; + + const QList draft{ + RuleSpec{.field = RuleField::kAuthor, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("author"), + .folderId = 3}, + RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral("core"), + .folderId = 2}, + }; + + RulePreview preview = previewRuleMatches(state, mods, draft); + CHECK(preview.counts == QList{3, 1}); + CHECK(preview.assignable == 1); + + const QList reversed{draft[1], draft[0]}; + preview = previewRuleMatches(state, mods, reversed); + CHECK(preview.counts == QList{4, 0}); + CHECK(preview.assignable == 1); + + QList withBlank{ + RuleSpec{.field = RuleField::kName, + .op = RuleOp::kContains, + .pattern = QStringLiteral(" "), + .folderId = 2}, + draft[0], + draft[1], + }; + preview = previewRuleMatches(state, mods, withBlank); + CHECK(preview.counts == QList{0, 3, 1}); + CHECK(preview.assignable == 1); +} + +TEST_CASE( + "folder_for manual placement beats rules, and root placement blocks " + "rules") { + SECTION("manual beats rule") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), + folder(3, kRootId, QStringLiteral("B"))}, + {{QStringLiteral("x.mod"), 3}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); + const Resolution res = folderFor(state, QStringLiteral("X.Mod"), + QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folderId == 3); + CHECK(res.placement == Placement::kManual); + } + + SECTION("root placement blocks rules") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, + {{QStringLiteral("x.mod"), kRootId}}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("x."), 2}}); + const Resolution res = folderFor(state, QStringLiteral("x.mod"), + QStringLiteral("Mod"), QStringLiteral("")); + CHECK(res.folderId == kRootId); + CHECK(res.placement == Placement::kManual); + } + + SECTION("first rule by position wins") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A")), + folder(3, kRootId, QStringLiteral("B"))}, + {}, + {{RuleField::kAuthor, RuleOp::kEquals, QStringLiteral("OSKAR"), 3}, + {RuleField::kName, RuleOp::kContains, QStringLiteral("core"), 2}}); + const Resolution res1 = + folderFor(state, QStringLiteral("o.core"), QStringLiteral("Core Stuff"), + QStringLiteral("Oskar")); + CHECK(res1.folderId == 3); + CHECK(res1.placement == Placement::kRule); + const Resolution res2 = + folderFor(state, QStringLiteral("p.core"), QStringLiteral("Core Stuff"), + QStringLiteral("Someone")); + CHECK(res2.folderId == 2); + CHECK(res2.placement == Placement::kRule); + } + + SECTION("ops are case-insensitive") { + const OrganizerState state = + makeState({folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kName, RuleOp::kEquals, + QStringLiteral("hello world"), 2}}); + CHECK(folderFor(state, QStringLiteral("a.b"), QStringLiteral("Hello World"), + QStringLiteral("")) + .folderId == 2); + CHECK(folderFor(state, QStringLiteral("a.b"), + QStringLiteral("Hello World!"), QStringLiteral("")) + .folderId == kRootId); + } + + SECTION("no package id or no match is ungrouped") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kName, RuleOp::kContains, QStringLiteral(""), 2}}); + CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), + QStringLiteral("")) + .folderId == kRootId); + CHECK(folderFor(state, std::nullopt, QStringLiteral("Anything"), + QStringLiteral("")) + .placement == Placement::kNone); + + const OrganizerState state2 = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, {}, + {{RuleField::kPackageId, RuleOp::kPrefix, QStringLiteral("zzz"), 2}}); + CHECK(folderFor(state2, QStringLiteral("a.b"), QStringLiteral("Mod"), + QStringLiteral("")) + .folderId == kRootId); + } +} + +namespace { + +struct NestedFixture { + OrganizerState state; + ModsByUuid mods; +}; + +NestedFixture makeNested() { + OrganizerState state = + makeState({folder(2, kRootId, QStringLiteral("Zeta")), + folder(3, 2, QStringLiteral("inner")), + folder(4, kRootId, QStringLiteral("alpha"))}, + {{QStringLiteral("a.one"), 2}, + {QStringLiteral("a.two"), 3}, + {QStringLiteral("a.three"), 3}, + {QStringLiteral("b.one"), 4}}); + ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("two"), + QStringLiteral("a.two"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Three"), + QStringLiteral("a.three"))}, + {QStringLiteral("u4"), + makeMod(QStringLiteral("u4"), QStringLiteral("B One"), + QStringLiteral("b.one"))}, + {QStringLiteral("u5"), + makeMod(QStringLiteral("u5"), QStringLiteral("loose"))}, + }; + return {std::move(state), std::move(mods)}; +} + +} // namespace + +TEST_CASE("build_tree computes tri-state checks across nested folders") { + auto [state, mods] = makeNested(); + const FolderNode root = buildTree( + state, mods, + {QStringLiteral("u1"), QStringLiteral("u2"), QStringLiteral("u3")}); + const FolderNode* zeta = findNode(root, 2); + const FolderNode* inner = findNode(root, 3); + const FolderNode* alpha = findNode(root, 4); + REQUIRE((zeta && inner && alpha)); + + CHECK(inner->total == 2); + CHECK(inner->enabled == 2); + CHECK(inner->check == CheckState::kOn); + + CHECK(zeta->total == 3); + CHECK(zeta->enabled == 3); + CHECK(zeta->check == CheckState::kOn); + + CHECK(alpha->total == 1); + CHECK(alpha->enabled == 0); + CHECK(alpha->check == CheckState::kOff); + + CHECK(root.total == 5); + CHECK(root.enabled == 3); + CHECK(root.check == CheckState::kPartial); + + const FolderNode root2 = buildTree(state, mods, {QStringLiteral("u2")}); + CHECK(findNode(root2, 3)->check == CheckState::kPartial); + CHECK(findNode(root2, 2)->check == CheckState::kPartial); +} + +TEST_CASE("build_tree reports depths and places ungrouped mods at root") { + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {}); + CHECK(root.depth == 0); + CHECK(findNode(root, 2)->depth == 1); + CHECK(findNode(root, 3)->depth == 2); + + REQUIRE(root.mods.size() == 1); + CHECK(root.mods[0].uuid == QStringLiteral("u5")); + CHECK(root.mods[0].placement == Placement::kNone); + CHECK_FALSE(root.mods[0].packageId.has_value()); +} + +TEST_CASE("build_tree sorts siblings and mods by name casefolded") { + auto [state, mods] = makeNested(); + const FolderNode root = buildTree(state, mods, {}); + REQUIRE(root.children.size() == 2); + CHECK(root.children[0].folder.name == QStringLiteral("alpha")); + CHECK(root.children[1].folder.name == QStringLiteral("Zeta")); + + const FolderNode* inner = findNode(root, 3); + REQUIRE(inner->mods.size() == 2); + CHECK(inner->mods[0].name == QStringLiteral("Three")); + CHECK(inner->mods[1].name == QStringLiteral("two")); +} + +TEST_CASE("build_tree query prunes nodes but counts cover all mods") { + auto [state, mods] = makeNested(); + TreeQuery query; + query.text = QStringLiteral("TWO"); + + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, query); + REQUIRE(root.children.size() == 1); + CHECK(root.children[0].folder.id == 2); + + const FolderNode* zeta = findNode(root, 2); + const FolderNode* inner = findNode(root, 3); + REQUIRE((zeta && inner)); + CHECK(zeta->mods.isEmpty()); + REQUIRE(inner->mods.size() == 1); + CHECK(inner->mods[0].uuid == QStringLiteral("u2")); + + CHECK(zeta->total == 3); + CHECK(zeta->enabled == 1); + CHECK(zeta->visibleTotal == 1); + CHECK(root.total == 5); + CHECK(root.visibleTotal == 1); + CHECK(root.mods.isEmpty()); +} + +TEST_CASE("an empty query does not prune empty folders") { + const OrganizerState state = + makeState({folder(2, kRootId, QStringLiteral("Empty"))}); + const FolderNode root = buildTree(state, {}, {}, TreeQuery{}); + REQUIRE(root.children.size() == 1); + CHECK(root.children[0].folder.id == 2); + CHECK(findNode(root, 2)->check == CheckState::kOff); +} + +TEST_CASE("build_tree filters by status, tags, provider and text") { + const OrganizerState state = makeState( + {}, {}, {}, + {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two"), QStringLiteral("Ludeon"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Three"))}, + }; + + const auto visible = [&](const TreeQuery& q) { + const FolderNode root = buildTree(state, mods, {QStringLiteral("u1")}, q); + QSet uuids; + for (const ModLeaf& leaf : root.allMods()) { + uuids.insert(leaf.uuid); + } + return uuids; + }; + + TreeQuery activeQ; + activeQ.status = StatusFilter::kActive; + CHECK(visible(activeQ) == QSet{QStringLiteral("u1")}); + + TreeQuery inactiveQ; + inactiveQ.status = StatusFilter::kInactive; + CHECK(visible(inactiveQ) == + QSet{QStringLiteral("u2"), QStringLiteral("u3")}); + + TreeQuery tag1Q; + tag1Q.tagIds = {1}; + CHECK(visible(tag1Q) == + QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + + TreeQuery tag12Q; + tag12Q.tagIds = {1, 2}; + CHECK(visible(tag12Q) == QSet{QStringLiteral("u1")}); + + TreeQuery steamQ; + steamQ.providerIds = {QStringLiteral("steam")}; + CHECK(visible(steamQ) == QSet{QStringLiteral("u1")}); + + TreeQuery textQ; + textQ.text = QStringLiteral("ludeon"); + CHECK(visible(textQ) == QSet{QStringLiteral("u2")}); + + TreeQuery pidQ; + pidQ.text = QStringLiteral("a.one"); + CHECK(visible(pidQ) == QSet{QStringLiteral("u1")}); +} + +TEST_CASE("mods sharing a package id share their placement and tags") { + const OrganizerState state = makeState( + {folder(2, kRootId, QStringLiteral("A"))}, + {{QStringLiteral("dup.mod"), 2}}, {}, {{QStringLiteral("dup.mod"), {7}}}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("Copy 1"), + QStringLiteral("Dup.Mod"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Copy 2"), + QStringLiteral("dup.mod"))}, + }; + + const FolderNode root = buildTree(state, mods, {}); + const FolderNode* a = findNode(root, 2); + REQUIRE(a != nullptr); + REQUIRE(a->mods.size() == 2); + CHECK(a->mods[0].uuid == QStringLiteral("u1")); + CHECK(a->mods[1].uuid == QStringLiteral("u2")); + CHECK(a->mods[0].tagIds == QSet{7}); + CHECK(a->mods[1].tagIds == QSet{7}); +} + +TEST_CASE("build_tree computes own counts and flags folders that have rules") { + auto [baseState, mods] = makeNested(); + QList nestedFolders; + for (const Folder& f : baseState.folders) { + if (f.id != kRootId) { + nestedFolders.append(f); + } + } + const OrganizerState state = makeState( + nestedFolders, baseState.placements, + {{RuleField::kName, RuleOp::kContains, QStringLiteral("zzz"), 3}}); + + TreeQuery query; + query.text = QStringLiteral("nothing"); + const FolderNode root = buildTree( + state, mods, {QStringLiteral("u5"), QStringLiteral("u1")}, query); + CHECK(root.ownTotal == 1); + CHECK(root.ownEnabled == 1); + CHECK(root.ownCheck() == CheckState::kOn); + CHECK(root.total == 5); + CHECK(root.check == CheckState::kPartial); + + const FolderNode full = buildTree(state, mods, {}); + QList ruledIds; + for (const FolderNode* node : full.walk()) { + if (node->hasRules) { + ruledIds.append(node->folder.id); + } + } + CHECK(ruledIds == QList{3}); + const FolderNode* zeta = findNode(full, 2); + REQUIRE(zeta != nullptr); + CHECK(zeta->ownTotal == 1); +} + +TEST_CASE("TreeFilter::query builds a query matching its preset") { + TreeFilter tf; + tf.key = QStringLiteral("tag:10"); + tf.label = QStringLiteral("Medieval"); + tf.count = 3; + tf.tagId = 10; + + const TreeQuery q = tf.query(QStringLiteral("sword")); + CHECK(q.tagIds == QSet{10}); + CHECK(q.text == QStringLiteral("sword")); + CHECK(q.status == StatusFilter::kAll); + CHECK(q.providerIds.isEmpty()); + CHECK_FALSE(q.isEmpty()); +} + +TEST_CASE( + "tree_filters preserves order, counts status and counts tags across mods") { + const Tag t1{.id = 1, + .name = QStringLiteral("Medieval"), + .color = QStringLiteral("#5eead4")}; + const Tag t2{.id = 2, + .name = QStringLiteral("Combat"), + .color = QStringLiteral("#fb923c")}; + const Tag t3{.id = 3, + .name = QStringLiteral("Magic"), + .color = QStringLiteral("#a3e635")}; + + const OrganizerState state = makeState( + {}, {}, {}, + {{QStringLiteral("a.one"), {1, 2}}, {QStringLiteral("a.two"), {1}}}, + {t1, t2, t3}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Three"), + QStringLiteral("a.three"), {}, QStringLiteral("steam"))}, + }; + + const QList filters = + treeFilters(mods, {QStringLiteral("u1")}, &state); + REQUIRE(filters.size() >= 3); + CHECK(filters[0].key == QStringLiteral("all")); + CHECK(filters[0].count == 3); + CHECK(filters[1].key == QStringLiteral("active")); + CHECK(filters[1].count == 1); + CHECK(filters[2].key == QStringLiteral("inactive")); + CHECK(filters[2].count == 2); + + QList providers; + for (const TreeFilter& f : filters) { + if (f.providerId) { + providers.append(f); + } + } + REQUIRE(providers.size() == 2); + CHECK(providers[0].key == QStringLiteral("provider:local")); + CHECK(providers[0].count == 1); + CHECK(providers[1].key == QStringLiteral("provider:steam")); + CHECK(providers[1].count == 2); + + QList tags; + for (const TreeFilter& f : filters) { + if (f.tagId) { + tags.append(f); + } + } + REQUIRE(tags.size() == 3); + CHECK(tags[0].key == QStringLiteral("tag:2")); + CHECK(tags[0].label == QStringLiteral("Combat")); + CHECK(tags[0].count == 1); + + CHECK(tags[1].key == QStringLiteral("tag:3")); + CHECK(tags[1].label == QStringLiteral("Magic")); + CHECK(tags[1].count == 0); + + CHECK(tags[2].key == QStringLiteral("tag:1")); + CHECK(tags[2].label == QStringLiteral("Medieval")); + CHECK(tags[2].count == 2); +} + +TEST_CASE("tree_filters counts all copies sharing a package id") { + const Tag t5{.id = 5, + .name = QStringLiteral("Overhaul"), + .color = QStringLiteral("#5eead4")}; + const OrganizerState state = + makeState({}, {}, {}, {{QStringLiteral("shared.mod"), {5}}}, {t5}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("Steam Copy"), + QStringLiteral("Shared.Mod"), {}, QStringLiteral("steam"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Local Copy"), + QStringLiteral("shared.mod"), {}, QStringLiteral("local"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Other Mod"), + QStringLiteral("other.mod"), {}, QStringLiteral("local"))}, + }; + + const QList filters = treeFilters(mods, {}, &state); + const auto it = std::find_if( + filters.cbegin(), filters.cend(), + [](const TreeFilter& f) { return f.key == QStringLiteral("tag:5"); }); + REQUIRE(it != filters.cend()); + CHECK(it->count == 2); + CHECK(it->label == QStringLiteral("Overhaul")); + CHECK(it->tagId == 5); +} + +TEST_CASE( + "tree_filters without state or without tags only produces presets and " + "providers") { + const ModsByUuid mods{{QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"))}}; + + const QList withoutState = treeFilters(mods, {}, nullptr); + QStringList keys; + for (const TreeFilter& f : withoutState) { + keys.append(f.key); + } + CHECK(keys == QStringList{QStringLiteral("all"), QStringLiteral("active"), + QStringLiteral("inactive"), + QStringLiteral("provider:local")}); + + const OrganizerState emptyState = makeState(); + const QList withEmptyState = treeFilters(mods, {}, &emptyState); + QStringList emptyKeys; + for (const TreeFilter& f : withEmptyState) { + emptyKeys.append(f.key); + } + CHECK(emptyKeys == keys); +} + +TEST_CASE("filtering tree with a tag query selects only tagged mods") { + const Tag t1{.id = 1, + .name = QStringLiteral("Tagged"), + .color = QStringLiteral("#5eead4")}; + const Tag t2{.id = 2, + .name = QStringLiteral("Empty"), + .color = QStringLiteral("#fb923c")}; + const OrganizerState state = makeState( + {}, {}, {}, + {{QStringLiteral("a.one"), {1}}, {QStringLiteral("a.two"), {1}}}, + {t1, t2}); + + const ModsByUuid mods{ + {QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("Mod One"), + QStringLiteral("a.one"))}, + {QStringLiteral("u2"), + makeMod(QStringLiteral("u2"), QStringLiteral("Mod Two"), + QStringLiteral("a.two"))}, + {QStringLiteral("u3"), + makeMod(QStringLiteral("u3"), QStringLiteral("Mod Three"), + QStringLiteral("a.three"))}, + }; + + const QList filters = treeFilters(mods, {}, &state); + const auto it1 = std::find_if( + filters.cbegin(), filters.cend(), + [](const TreeFilter& f) { return f.key == QStringLiteral("tag:1"); }); + REQUIRE(it1 != filters.cend()); + + const FolderNode tree1 = buildTree(state, mods, {}, it1->query()); + QSet visible1; + for (const ModLeaf& leaf : tree1.allMods()) { + visible1.insert(leaf.uuid); + } + CHECK(visible1 == QSet{QStringLiteral("u1"), QStringLiteral("u2")}); + + const auto it2 = std::find_if( + filters.cbegin(), filters.cend(), + [](const TreeFilter& f) { return f.key == QStringLiteral("tag:2"); }); + REQUIRE(it2 != filters.cend()); + const FolderNode emptyTree = buildTree(state, mods, {}, it2->query()); + CHECK(emptyTree.allMods().isEmpty()); + CHECK(emptyTree.visibleTotal == 0); + + const FolderNode narrowed = + buildTree(state, mods, {}, it1->query(QStringLiteral("Two"))); + QSet visibleNarrowed; + for (const ModLeaf& leaf : narrowed.allMods()) { + visibleNarrowed.insert(leaf.uuid); + } + CHECK(visibleNarrowed == QSet{QStringLiteral("u2")}); +} diff --git a/tests/core/organizer/test_service.cpp b/tests/core/organizer/test_service.cpp new file mode 100644 index 0000000..0d5f1f7 --- /dev/null +++ b/tests/core/organizer/test_service.cpp @@ -0,0 +1,754 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/models/metadata/structures.hpp" +#include "core/organizer/db.hpp" +#include "core/organizer/defaults.hpp" +#include "core/organizer/models.hpp" +#include "core/organizer/resolve.hpp" +#include "core/organizer/service.hpp" +#include "test_util.hpp" + +using namespace px::modrim; +using namespace px::modrim::organizer; +using organizer_test::expect; +using organizer_test::expectError; +using organizer_test::wait; +using test_support::TestApplication; + +namespace { + +struct FakeContext { + QHash allMods; + QStringList activeUuids; + + void add(const QString& uuid, const QString& name, const QString& pid, + const QString& provider = QStringLiteral("local")) { + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = provider; + mod->packageId = CaseInsensitiveStr(QStringView(pid)); + allMods.insert(uuid, mod); + } +}; + +struct ChangeCounter { + int count = 0; + void record(const std::monostate&) { ++count; } +}; + +struct TestHarness { + TestApplication app; + QTemporaryDir dir; + FakeContext ctx; + OrganizerDb db; + OrganizerService svc; + ChangeCounter changes; + + explicit TestHarness() + : dir(), + db(dir.filePath(QStringLiteral("organizer.db"))), + svc([this]() -> const auto& { return ctx.allMods; }, + [this]() -> const auto& { return ctx.activeUuids; }, db) { + REQUIRE(dir.isValid()); + svc.changed.connect([this](const std::monostate& m) { changes.record(m); }); + wait(svc.init()); + // Reset counter after init notification so tests count mutations only. + changes.count = 0; + } + + ~TestHarness() { wait(svc.shutdown()); } +}; + +void checkRootedTree(const OrganizerState& state) { + for (const Folder& folder : state.folders) { + qint64 current = folder.id; + int depth = 0; + while (current != kRootId && depth <= kMaxDepth) { + const auto it = state.folders.constFind(current); + REQUIRE(it != state.folders.cend()); + REQUIRE(it->parentId.has_value()); + current = *it->parentId; + ++depth; + } + CHECK(current == kRootId); + CHECK(depth <= kMaxDepth); + } +} + +} // namespace + +TEST_CASE("bulk folder expansion and collapse persists") { + TestHarness h; + const Folder parent = + expect(wait(h.svc.createFolder(QStringLiteral("Parent")))); + const Folder child = + expect(wait(h.svc.createFolder(QStringLiteral("Child"), parent.id))); + const Folder other = + expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + + expect(wait(h.svc.setAllCollapsed(true))); + OrganizerState persisted = expect(wait(h.db.load())); + CHECK(persisted.folders.value(parent.id).collapsed); + CHECK(persisted.folders.value(child.id).collapsed); + CHECK(persisted.folders.value(other.id).collapsed); + + expect(wait(h.svc.setCollapsed(child.id, false))); + expect(wait(h.svc.setAllCollapsed(false))); + persisted = expect(wait(h.db.load())); + CHECK_FALSE(persisted.folders.value(parent.id).collapsed); + CHECK_FALSE(persisted.folders.value(child.id).collapsed); + CHECK_FALSE(persisted.folders.value(other.id).collapsed); +} + +TEST_CASE("folder depth cannot exceed maximum depth") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); + CHECK(h.changes.count == 3); + + expectError(wait(h.svc.createFolder(QStringLiteral("D"), c.id)), + QStringLiteral("at most 3 levels")); + CHECK(h.changes.count == 3); + CHECK(h.svc.state().folders.size() == 4); +} + +TEST_CASE("moving a folder respects subtree height") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); + + expectError(wait(h.svc.moveFolder(x.id, b.id)), + QStringLiteral("at most 3 levels")); + + expect(wait(h.svc.moveFolder(x.id, a.id))); + CHECK(h.svc.state().folders.value(x.id).parentId == a.id); + CHECK(h.svc.state().folders.value(y.id).parentId == x.id); + CHECK(h.changes.count == 5); +} + +TEST_CASE("moving a folder into its own subtree or moving root is rejected") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + + expectError(wait(h.svc.moveFolder(a.id, a.id)), + QStringLiteral("into itself")); + expectError(wait(h.svc.moveFolder(a.id, b.id)), + QStringLiteral("into itself")); + expectError(wait(h.svc.moveFolder(kRootId, a.id))); + expectError(wait(h.db.setParent(a.id, a.id)), QStringLiteral("into itself")); + expectError(wait(h.db.setParent(a.id, b.id)), QStringLiteral("into itself")); + + CHECK(h.changes.count == 2); + CHECK(h.svc.state().folders.value(a.id).parentId == kRootId); +} + +TEST_CASE("concurrent opposite folder moves cannot create a cycle") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + const Folder a = expect(wait( + h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("a.one")}))); + const Folder b = expect(wait( + h.svc.createFolderFrom(QStringLiteral("B"), {QStringLiteral("a.two")}))); + + auto movingA = h.svc.moveFolder(a.id, b.id); + auto movingB = h.svc.moveFolder(b.id, a.id); + const auto movedA = wait(std::move(movingA)); + const auto movedB = wait(std::move(movingB)); + + REQUIRE(movedA.has_value() != movedB.has_value()); + expectError(movedA ? movedB : movedA, QStringLiteral("into itself")); + checkRootedTree(expect(wait(h.db.load()))); + wait(h.svc.init()); + CHECK(h.svc.tree().total == 2); +} + +TEST_CASE("concurrent folder moves enforce committed depth and height") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y")))); + + bool moveAncestorFirst = false; + SECTION("subtree height changes first") {} + SECTION("destination depth changes first") { moveAncestorFirst = true; } + auto first = moveAncestorFirst ? h.svc.moveFolder(a.id, y.id) + : h.svc.moveFolder(x.id, b.id); + auto second = moveAncestorFirst ? h.svc.moveFolder(x.id, b.id) + : h.svc.moveFolder(a.id, y.id); + const auto movedFirst = wait(std::move(first)); + const auto movedSecond = wait(std::move(second)); + + REQUIRE(movedFirst.has_value() != movedSecond.has_value()); + expectError(movedFirst ? movedSecond : movedFirst, + QStringLiteral("at most %1 levels").arg(kMaxDepth)); + checkRootedTree(expect(wait(h.db.load()))); +} + +TEST_CASE("folder creation rechecks depth after a concurrent parent move") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder child = + expect(wait(h.svc.createFolder(QStringLiteral("Child"), a.id))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B")))); + QStringList packageIds; + SECTION("empty folder") {} + SECTION("folder with mods") { packageIds.append(QStringLiteral("a.one")); } + + auto moving = h.svc.moveFolder(a.id, b.id); + auto creating = packageIds.isEmpty() + ? h.svc.createFolder(QStringLiteral("Too deep"), child.id) + : h.svc.createFolderFrom(QStringLiteral("Too deep"), + packageIds, child.id); + expect(wait(std::move(moving))); + expectError(wait(std::move(creating)), + QStringLiteral("at most %1 levels").arg(kMaxDepth)); + const OrganizerState persisted = expect(wait(h.db.load())); + checkRootedTree(persisted); + CHECK(persisted.folders.size() == 4); + CHECK(persisted.placements.isEmpty()); +} + +TEST_CASE("persisted folder cycles do not hang organizer validation") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder other = + expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + wait(h.db.close()); + const QString connection = QUuid::createUuid().toString(); + { + QSqlDatabase raw = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connection); + raw.setDatabaseName(h.dir.filePath(QStringLiteral("organizer.db"))); + REQUIRE(raw.open()); + QSqlQuery query(raw); + REQUIRE(query.prepare( + QStringLiteral("UPDATE folders SET parent_id = ? WHERE id = ?"))); + query.addBindValue(b.id); + query.addBindValue(a.id); + REQUIRE(query.exec()); + } + QSqlDatabase::removeDatabase(connection); + wait(h.svc.init()); + + const QString depthError = QStringLiteral("at most %1 levels").arg(kMaxDepth); + expectError(wait(h.svc.createFolder(QStringLiteral("Child"), a.id)), + depthError); + CHECK(expect(h.svc.folderMoveTargets(a.id)).isEmpty()); + expectError(wait(h.db.createFolder(a.id, QStringLiteral("Child"))), + depthError); + expectError(wait(h.db.setParent(other.id, a.id)), depthError); +} + +TEST_CASE( + "folder creation and rename reject duplicate names case-insensitively") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("Graphics")))); + expectError(wait(h.svc.createFolder(QStringLiteral(" graphics "))), + QStringLiteral("already exists")); + + const Folder other = + expect(wait(h.svc.createFolder(QStringLiteral("Other")))); + expectError(wait(h.svc.renameFolder(other.id, QStringLiteral("GRAPHICS"))), + QStringLiteral("already exists")); + + const Folder inOther = + expect(wait(h.svc.createFolder(QStringLiteral("Graphics"), other.id))); + CHECK(inOther.parentId == other.id); + + expect(wait(h.svc.renameFolder(a.id, QStringLiteral("graphics")))); + CHECK(h.svc.state().folders.value(a.id).name == QStringLiteral("graphics")); + CHECK(h.changes.count == 4); +} + +TEST_CASE("empty folder name is rejected") { + TestHarness h; + expectError(wait(h.svc.createFolder(QStringLiteral(" "))), + QStringLiteral("cannot be empty")); + CHECK(h.changes.count == 0); +} + +TEST_CASE("create_folder_from emits once and rolls back on duplicate") { + TestHarness h; + const Folder f = expect(wait(h.svc.createFolderFrom( + QStringLiteral("Core"), + {QStringLiteral("mod.a"), QStringLiteral("mod.b")}))); + CHECK(h.changes.count == 1); + CHECK(f.name == QStringLiteral("Core")); + CHECK(h.svc.state().placements.value(QStringLiteral("mod.a")) == f.id); + CHECK(h.svc.state().placements.value(QStringLiteral("mod.b")) == f.id); + + const OrganizerState stateBefore = h.svc.state(); + const int changesBefore = h.changes.count; + + expectError(wait(h.svc.createFolderFrom(QStringLiteral("core"), + {QStringLiteral("mod.c")}))); + CHECK(h.changes.count == changesBefore); + CHECK(h.svc.state() == stateBefore); + + const OrganizerState dbState = expect(wait(h.db.load())); + CHECK_FALSE(dbState.placements.contains(QStringLiteral("mod.c"))); + CHECK(dbState.folders.size() == stateBefore.folders.size()); +} + +TEST_CASE("deleting a folder ungroups mods to root manual placement") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.activeUuids = {QStringLiteral("u1")}; + + const Folder a = expect(wait( + h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("A.One")}))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); + expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kPackageId, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("a."), + .folderId = a.id}}))); + CHECK(h.changes.count == 4); + + QStringList uuids = expect(h.svc.folderModUuids(a.id)); + std::sort(uuids.begin(), uuids.end()); + CHECK(uuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + + expect(wait(h.svc.deleteFolder(a.id))); + CHECK(h.changes.count == 5); + CHECK(h.svc.state().folders.keys() == QList{kRootId}); + CHECK(h.svc.state().rules.isEmpty()); + + const FolderNode root = h.svc.tree(); + QStringList rootUuids; + for (const ModLeaf& leaf : root.mods) { + rootUuids.append(leaf.uuid); + CHECK(leaf.placement == Placement::kManual); + } + std::sort(rootUuids.begin(), rootUuids.end()); + CHECK(rootUuids == QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + CHECK(root.total == 2); + CHECK(root.enabled == 1); + CHECK(root.check == CheckState::kPartial); +} + +TEST_CASE("place, ungroup and reset_to_rules alter placement source") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + expect(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral(" one "), + .folderId = a.id}}))); + CHECK(h.svc.state().rules[0].pattern == QStringLiteral("one")); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); + + expect(wait(h.svc.ungroup({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().mods[0].placement == Placement::kManual); + + expect(wait(h.svc.resetToRules({QStringLiteral("a.one")}))); + CHECK(h.svc.tree().children[0].mods[0].placement == Placement::kRule); + CHECK(h.changes.count == 4); +} + +TEST_CASE( + "rules validation rejects root target, empty pattern or missing folder") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("x"), + .folderId = kRootId}})), + QStringLiteral("root")); + + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral(" "), + .folderId = a.id}})), + QStringLiteral("pattern cannot be empty")); + + expectError(wait(h.svc.setRules({RuleSpec{.field = RuleField::kName, + .op = RuleOp::kEquals, + .pattern = QStringLiteral("x"), + .folderId = 999}}))); + CHECK(h.changes.count == 1); +} + +TEST_CASE("tags lifecycle, validation and removal cascade") { + TestHarness h; + const Tag tag = expect( + wait(h.svc.createTag(QStringLiteral("QoL"), QStringLiteral("#aabbcc")))); + + expectError( + wait(h.svc.createTag(QStringLiteral("qol"), QStringLiteral("#000000"))), + QStringLiteral("already exists")); + expectError( + wait(h.svc.createTag(QStringLiteral("Other"), QStringLiteral("red"))), + QStringLiteral("not a valid color")); + + expect(wait(h.svc.setModTags({QStringLiteral("A.Mod")}, {tag.id}, {}))); + CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")) == + QSet{tag.id}); + + expectError(wait(h.svc.setModTags({QStringLiteral("a.mod")}, {999}, {}))); + + expect(wait(h.svc.deleteTag(tag.id))); + CHECK(h.svc.state().tags.isEmpty()); + CHECK(h.svc.state().modTags.value(QStringLiteral("a.mod")).isEmpty()); + CHECK(h.changes.count == 3); +} + +TEST_CASE("service state persists across instances") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString dbFile = dir.filePath(QStringLiteral("organizer.db")); + FakeContext ctx; + + Folder folder; + { + OrganizerDb db1(dbFile); + OrganizerService svc1([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, + db1); + wait(svc1.init()); + folder = expect(wait(svc1.createFolder(QStringLiteral("Kept")))); + wait(svc1.shutdown()); + } + + { + OrganizerDb db2(dbFile); + OrganizerService svc2([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, + db2); + wait(svc2.init()); + CHECK(svc2.state().folders.contains(folder.id)); + CHECK(svc2.state().folders.value(folder.id).name == QStringLiteral("Kept")); + wait(svc2.shutdown()); + } +} + +TEST_CASE("init signals readiness and emits changed") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + FakeContext ctx; + OrganizerDb db(dir.filePath(QStringLiteral("organizer.db"))); + OrganizerService svc([&]() -> const auto& { return ctx.allMods; }, + [&]() -> const auto& { return ctx.activeUuids; }, db); + ChangeCounter counter; + svc.changed.connect([&](const std::monostate& m) { counter.record(m); }); + + CHECK_FALSE(svc.ready()); + wait(svc.init()); + CHECK(svc.ready()); + CHECK(counter.count == 1); + wait(svc.shutdown()); +} + +TEST_CASE("folder_toggle enables partial/off and disables full") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Loose"), + QStringLiteral("loose.mod")); + + const Folder a = expect(wait( + h.svc.createFolderFrom(QStringLiteral("A"), {QStringLiteral("a.one")}))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + expect(wait(h.svc.place({QStringLiteral("a.two")}, b.id))); + + h.ctx.activeUuids = {QStringLiteral("u1")}; + FolderToggle toggle = expect(h.svc.folderToggle(a.id)); + toggle.enable.sort(); + CHECK(toggle.enable == + QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + CHECK(toggle.disable.isEmpty()); + + h.ctx.activeUuids = {QStringLiteral("u1"), QStringLiteral("u2")}; + toggle = expect(h.svc.folderToggle(a.id)); + toggle.disable.sort(); + CHECK(toggle.enable.isEmpty()); + CHECK(toggle.disable == + QStringList{QStringLiteral("u1"), QStringLiteral("u2")}); + + toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); + CHECK(toggle.enable == QStringList{QStringLiteral("u3")}); + CHECK(toggle.disable.isEmpty()); + + h.ctx.activeUuids = {QStringLiteral("u3")}; + toggle = expect(h.svc.folderToggle(kRootId, /*recursive=*/false)); + CHECK(toggle.enable.isEmpty()); + CHECK(toggle.disable == QStringList{QStringLiteral("u3")}); + + CHECK(expect(h.svc.folderModUuids(kRootId, /*recursive=*/false)) == + QStringList{QStringLiteral("u3")}); +} + +TEST_CASE("folder_move_targets respects subtree depth and duplicate names") { + TestHarness h; + const Folder a = expect(wait(h.svc.createFolder(QStringLiteral("A")))); + const Folder b = expect(wait(h.svc.createFolder(QStringLiteral("B"), a.id))); + const Folder c = expect(wait(h.svc.createFolder(QStringLiteral("C"), b.id))); + const Folder x = expect(wait(h.svc.createFolder(QStringLiteral("X")))); + const Folder y = expect(wait(h.svc.createFolder(QStringLiteral("Y"), x.id))); + const Folder clash = + expect(wait(h.svc.createFolder(QStringLiteral("Clash")))); + const Folder innerX = + expect(wait(h.svc.createFolder(QStringLiteral("X"), clash.id))); + + CHECK(expect(h.svc.folderMoveTargets(b.id)) == + QSet{x.id, clash.id, kRootId}); + CHECK(expect(h.svc.folderMoveTargets(x.id)) == QSet{a.id}); + CHECK(expect(h.svc.folderMoveTargets(c.id)) == + QSet{a.id, x.id, y.id, clash.id, innerX.id, kRootId}); + expectError(h.svc.folderMoveTargets(kRootId)); +} + +TEST_CASE("service tree_filters counts status and providers") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.activeUuids = {QStringLiteral("u1")}; + + QHash counts; + for (const TreeFilter& f : h.svc.treeFilters()) { + counts[f.key] = f.count; + } + CHECK(counts.value(QStringLiteral("all")) == 2); + CHECK(counts.value(QStringLiteral("active")) == 1); + CHECK(counts.value(QStringLiteral("inactive")) == 1); + CHECK(counts.value(QStringLiteral("provider:local")) == 2); +} + +TEST_CASE("service tree_filters with persisted tags and empty tag") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Two"), + QStringLiteral("a.two")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Three"), + QStringLiteral("a.three")); + + const Tag t1 = expect(wait( + h.svc.createTag(QStringLiteral("Medieval"), QStringLiteral("#5eead4")))); + const Tag t2 = expect(wait( + h.svc.createTag(QStringLiteral("Combat"), QStringLiteral("#fb923c")))); + const Tag t3 = expect(wait( + h.svc.createTag(QStringLiteral("Empty"), QStringLiteral("#a3e635")))); + + expect(wait(h.svc.setModTags({QStringLiteral("a.one")}, {t1.id, t2.id}, {}))); + expect(wait(h.svc.setModTags({QStringLiteral("a.two")}, {t1.id}, {}))); + + QHash filterMap; + for (const TreeFilter& f : h.svc.treeFilters()) { + filterMap[f.key] = f; + } + + const QString k1 = QStringLiteral("tag:%1").arg(t1.id); + const QString k2 = QStringLiteral("tag:%1").arg(t2.id); + const QString k3 = QStringLiteral("tag:%1").arg(t3.id); + + REQUIRE(filterMap.contains(k1)); + CHECK(filterMap[k1].count == 2); + CHECK(filterMap[k1].label == QStringLiteral("Medieval")); + + REQUIRE(filterMap.contains(k2)); + CHECK(filterMap[k2].count == 1); + CHECK(filterMap[k2].label == QStringLiteral("Combat")); + + REQUIRE(filterMap.contains(k3)); + CHECK(filterMap[k3].count == 0); + CHECK(filterMap[k3].label == QStringLiteral("Empty")); + + expect(wait(h.svc.deleteTag(t2.id))); + filterMap.clear(); + for (const TreeFilter& f : h.svc.treeFilters()) { + filterMap[f.key] = f; + } + CHECK_FALSE(filterMap.contains(k2)); + CHECK(filterMap.contains(k1)); +} + +TEST_CASE("service tree_filters counts shared package ids across copies") { + TestHarness h; + h.ctx.add(QStringLiteral("u1"), QStringLiteral("Steam Copy"), + QStringLiteral("shared.pkg"), QStringLiteral("steam")); + h.ctx.add(QStringLiteral("u2"), QStringLiteral("Local Copy"), + QStringLiteral("shared.pkg"), QStringLiteral("local")); + h.ctx.add(QStringLiteral("u3"), QStringLiteral("Other Mod"), + QStringLiteral("other.pkg"), QStringLiteral("local")); + + const Tag tag = expect(wait( + h.svc.createTag(QStringLiteral("Overhaul"), QStringLiteral("#5eead4")))); + expect(wait(h.svc.setModTags({QStringLiteral("shared.pkg")}, {tag.id}, {}))); + + QHash counts; + for (const TreeFilter& f : h.svc.treeFilters()) { + counts[f.key] = f.count; + } + CHECK(counts.value(QStringLiteral("tag:%1").arg(tag.id)) == 2); +} + +TEST_CASE("add_standard_rules is fresh and idempotent") { + TestHarness h; + const int total = static_cast(standardRules().size()); + QSet names; + for (const StandardRule& r : standardRules()) { + names.insert(r.folder); + } + + CHECK(expect(wait(h.svc.addStandardRules())) == total); + CHECK(h.changes.count == 1); + + QSet topFolders; + for (const Folder& f : h.svc.state().folders) { + if (f.parentId == kRootId) { + topFolders.insert(f.name); + } + } + CHECK(topFolders == names); + CHECK(h.svc.state().folders.size() == names.size() + 1); + CHECK(h.svc.state().rules.size() == total); + + CHECK(expect(wait(h.svc.addStandardRules())) == 0); + CHECK(h.changes.count == 1); + CHECK(h.svc.state().rules.size() == total); + CHECK(h.svc.state().folders.size() == names.size() + 1); +} + +TEST_CASE( + "add_standard_rules reuses existing folders and preserves user rules") { + TestHarness h; + const Folder official = + expect(wait(h.svc.createFolder(QStringLiteral("OFFICIAL")))); + const Folder mine = expect(wait(h.svc.createFolder(QStringLiteral("Mine")))); + expect(wait(h.svc.setRules({ + RuleSpec{.field = RuleField::kAuthor, + .op = RuleOp::kContains, + .pattern = QStringLiteral("me"), + .folderId = mine.id}, + RuleSpec{.field = RuleField::kPackageId, + .op = RuleOp::kPrefix, + .pattern = QStringLiteral("SARG."), + .folderId = mine.id}, + }))); + h.changes.count = 0; + + const int total = static_cast(standardRules().size()); + CHECK(expect(wait(h.svc.addStandardRules())) == total - 1); + CHECK(h.changes.count == 1); + + QStringList names; + for (const Folder& f : h.svc.state().folders) { + names.append(f.name); + } + CHECK_FALSE(names.contains(QStringLiteral("Official"))); + CHECK_FALSE(names.contains(QStringLiteral("Alpha Mods"))); + CHECK(names.count(QStringLiteral("OFFICIAL")) == 1); + + const auto& rules = h.svc.state().rules; + REQUIRE(rules.size() >= 3); + CHECK(h.svc.state().folders.value(rules[0].folderId).name == + QStringLiteral("Mine")); + CHECK(rules[0].pattern == QStringLiteral("me")); + CHECK(h.svc.state().folders.value(rules[1].folderId).name == + QStringLiteral("Mine")); + CHECK(rules[1].pattern == QStringLiteral("SARG.")); + + // The first standard rule for Official got re-targeted to the existing + // OFFICIAL folder. + CHECK(rules[2].folderId == official.id); +} + +TEST_CASE("standard rules resolve mods correctly") { + TestHarness h; + expect(wait(h.svc.addStandardRules())); + const OrganizerState& state = h.svc.state(); + + const auto folderName = [&](const QString& pid, + const QString& name = QStringLiteral("Mod"), + const QString& author = {}) { + const auto [fid, _] = folderFor(state, pid, name, author); + return state.folders.value(fid).name; + }; + + CHECK(folderName(QStringLiteral("oskarpotocki.vfe.core")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("someone.vfe"), QStringLiteral("Mod"), + QStringLiteral("Oskar Potocki, Taranchuk")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("ludeon.rimworld.biotech"), + QStringLiteral("Biotech")) == QStringLiteral("Official")); + CHECK(folderName(QStringLiteral("brrainz.harmony"), + QStringLiteral("Harmony")) == + QStringLiteral("Frameworks & Libraries")); + CHECK(folderName(QStringLiteral("ceteam.combatextended"), + QStringLiteral("Combat Extended")) == + QStringLiteral("Combat Extended")); + CHECK(folderName(QStringLiteral("sarg.alphagenes")) == + QStringLiteral("Alpha Mods")); + CHECK(folderName(QStringLiteral("bs.performance"), + QStringLiteral("Performance Fish")) == + QStringLiteral("Performance")); + CHECK( + folderName(QStringLiteral("oskarpotocki.vanillafactionsexpanded.core")) == + QStringLiteral("Frameworks & Libraries")); + CHECK(folderName(QStringLiteral("vanillaracesexpanded.sanguophage")) == + QStringLiteral("Vanilla Expanded")); + CHECK(folderName(QStringLiteral("dubwise.dubsperformanceanalyzer.steam")) == + QStringLiteral("Performance")); + CHECK(folderName(QStringLiteral("dubwise.rimatomics")) == + QStringLiteral("Dubs Mods")); + CHECK(folderName(QStringLiteral("brrainz.achtung"), + QStringLiteral("Achtung!")) == + QStringLiteral("Quality of Life")); + CHECK(folderName(QStringLiteral("brrainz.jobsofopportunity")) == + QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("smartkar.athena.framework"), + QStringLiteral("Athena Framework")) == + QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("someone.perf"), + QStringLiteral("Performance Tweaks")) == + QStringLiteral("Root")); + CHECK(folderName(QStringLiteral("someone.other"), QStringLiteral("Other")) == + QStringLiteral("Root")); +} + +TEST_CASE("standard package_id rules are not shadowed") { + TestHarness h; + expect(wait(h.svc.addStandardRules())); + const OrganizerState& state = h.svc.state(); + + for (const StandardRule& rule : standardRules()) { + if (rule.field != RuleField::kPackageId) { + continue; + } + const QString probe = rule.op == RuleOp::kEquals + ? rule.pattern + : rule.pattern + QStringLiteral("x"); + const auto [fid, _] = folderFor(state, probe, QStringLiteral("Mod"), {}); + CHECK(state.folders.value(fid).name == rule.folder); + } +} diff --git a/tests/core/organizer/test_util.hpp b/tests/core/organizer/test_util.hpp new file mode 100644 index 0000000..699b7eb --- /dev/null +++ b/tests/core/organizer/test_util.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include + +#include + +#include + +#include "../test_support.hpp" +#include "core/error.hpp" + +namespace organizer_test { + +// Runs a coroutine to completion on the Qt event loop. Needs a +// test_support::TestApplication in scope. +template +T wait(QCoro::Task task) { + return QCoro::waitFor(std::move(task)); +} + +template +T expect(px::modrim::Result result) { + REQUIRE(result.has_value()); + return std::move(*result); +} +inline void expect(const px::modrim::Result& result) { + REQUIRE(result.has_value()); +} + +template +void expectError(const px::modrim::Result& result, + const QString& fragment = {}) { + REQUIRE_FALSE(result.has_value()); + if (!fragment.isEmpty()) { + CHECK(result.error().message.contains(fragment)); + } +} + +} // namespace organizer_test diff --git a/tests/core/providers/test_factory.cpp b/tests/core/providers/test_factory.cpp new file mode 100644 index 0000000..9ee90ad --- /dev/null +++ b/tests/core/providers/test_factory.cpp @@ -0,0 +1,46 @@ +#include +#include + +#include + +#include "core/config.hpp" +#include "core/providers/factory.hpp" +#include "core/providers/local.hpp" + +using px::modrim::PathConfig; +using px::modrim::providers::createProviders; +using px::modrim::providers::SteamWorkshopModProvider; + +TEST_CASE("no configured paths produce no providers") { + CHECK(createProviders(PathConfig{}).empty()); +} + +TEST_CASE( + "each configured path yields its providers in core, local, downloaded, " + "steam order") { + PathConfig paths; + paths.game = "/games/rimworld"; + paths.local = "/mods"; + paths.workshop = "/steam/workshop"; + + const auto providers = createProviders(paths); + QStringList ids; + for (const auto& provider : providers) { + ids.append(provider->providerId()); + } + CHECK(ids == QStringList{"core", "local", "downloaded", "steam"}); + CHECK(providers[0]->path() == QStringLiteral("/games/rimworld")); + CHECK(providers[1]->path() == QStringLiteral("/mods")); + CHECK(providers[2]->path() == QStringLiteral("/mods")); + CHECK(providers[3]->path() == QStringLiteral("/steam/workshop")); + CHECK(dynamic_cast(providers[3].get()) != nullptr); +} + +TEST_CASE("only the configured paths produce providers") { + PathConfig paths; + paths.local = "/mods"; + const auto providers = createProviders(paths); + REQUIRE(providers.size() == 2); + CHECK(providers[0]->providerId() == QStringLiteral("local")); + CHECK(providers[1]->providerId() == QStringLiteral("downloaded")); +} diff --git a/tests/core/providers/test_providers.cpp b/tests/core/providers/test_providers.cpp new file mode 100644 index 0000000..62c4963 --- /dev/null +++ b/tests/core/providers/test_providers.cpp @@ -0,0 +1,247 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/providers/core.hpp" +#include "core/providers/local.hpp" + +using namespace px::modrim::providers; +using px::modrim::ListedModPtr; +using px::modrim::Result; + +namespace { + +QCoreApplication& eventLoop() { + static int argc = 0; + static QCoreApplication app(argc, nullptr); + return app; +} + +// Runs a discover() coroutine to completion on the Qt event loop. +template +T runToCompletion(QCoro::Task task) { + QCoreApplication& app = eventLoop(); + std::optional result; + auto driver = [](QCoro::Task inner, + std::optional* out) -> QCoro::Task { + *out = co_await std::move(inner); + QCoreApplication::quit(); + }; + driver(std::move(task), &result); + // A discover() that never awaits finishes before the loop starts. + if (!result) { + app.exec(); + } + REQUIRE(result.has_value()); + return *result; +} + +// Writes a mod directory with About/About.xml, and optionally +// About/PublishedFileId.txt. +QString makeMod(const QString& root, const QString& folder, + const QString& packageId, bool withPublishedFileId) { + const QString modPath = QDir(root).filePath(folder); + REQUIRE(QDir(modPath).mkpath(QStringLiteral("About"))); + + QFile about(QDir(modPath).filePath(QStringLiteral("About/About.xml"))); + REQUIRE(about.open(QIODevice::WriteOnly)); + about.write( + QStringLiteral("%1%1" + "
  • 1.5
  • " + "
    ") + .arg(packageId) + .toUtf8()); + + if (withPublishedFileId) { + QFile pfid( + QDir(modPath).filePath(QStringLiteral("About/PublishedFileId.txt"))); + REQUIRE(pfid.open(QIODevice::WriteOnly)); + pfid.write("123456"); + } + return modPath; +} + +// In-memory MetadataCacheStore. `serveCached` makes every candidate a cache +// hit. +class FakeCache final : public MetadataCacheStore { + public: + bool serveCached = false; + ListedModPtr cachedMod; + int getCalls = 0; + QList stored; + + QCoro::Task getMods( + QList candidates, QString /*target_version*/) override { + ++getCalls; + px::modrim::providers::CacheLookup lookup; + if (serveCached) { + lookup.cached.append(cachedMod); + } else { + lookup.missing = candidates; + } + co_return lookup; + } + + QCoro::Task putMods(QList entries, + QString /*target_version*/) override { + stored.append(entries); + co_return; + } +}; + +} // namespace + +TEST_CASE("workshop provider discovers mods that carry a published file id") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString workshop = root.filePath(QStringLiteral("workshop")); + const QString modPath = makeMod(workshop, QStringLiteral("123456"), + QStringLiteral("author.workshop"), true); + + SteamWorkshopModProvider provider(workshop); + CHECK(provider.providerId() == QStringLiteral("steam")); + CHECK(provider.path() == workshop); + + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + const ListedModPtr mod = discovered->constBegin().value(); + CHECK(mod->modPath() == modPath); + CHECK(mod->providerId == QStringLiteral("steam")); +} + +TEST_CASE( + "local and downloaded providers split mods on the published file id") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString local = root.path(); + const QString plain = makeMod(local, QStringLiteral("plain"), + QStringLiteral("local.mod"), false); + const QString downloaded = makeMod(local, QStringLiteral("downloaded"), + QStringLiteral("dl.mod"), true); + + LocalModProvider localProvider(local); + DownloadedModProvider downloadedProvider(local); + CHECK(localProvider.providerId() == QStringLiteral("local")); + CHECK(downloadedProvider.providerId() == QStringLiteral("downloaded")); + + const auto localMods = + runToCompletion(localProvider.discover(QStringLiteral("1.5"))); + REQUIRE(localMods.has_value()); + REQUIRE(localMods->size() == 1); + CHECK(localMods->contains(plain)); + + const auto downloadedMods = + runToCompletion(downloadedProvider.discover(QStringLiteral("1.5"))); + REQUIRE(downloadedMods.has_value()); + REQUIRE(downloadedMods->size() == 1); + CHECK(downloadedMods->contains(downloaded)); +} + +TEST_CASE("providers parse several mods through the thread pool") { + QTemporaryDir root; + REQUIRE(root.isValid()); + for (const char* folder : {"a", "b", "c"}) { + makeMod(root.path(), QString::fromUtf8(folder), + QStringLiteral("pool.%1").arg(QString::fromUtf8(folder)), false); + } + + QThreadPool pool; + LocalModProvider provider(root.path(), &pool); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + CHECK(discovered->size() == 3); + for (const ListedModPtr& mod : *discovered) { + CHECK(mod->valid); + } +} + +TEST_CASE("core provider reads mods from the game's Data directory") { + QTemporaryDir game; + REQUIRE(game.isValid()); + CoreModProvider provider(game.path()); + CHECK(provider.providerId() == QStringLiteral("core")); + CHECK(provider.color() == QStringLiteral("#e67e22")); + + // No Data/ directory: nothing to discover. + const auto missing = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(missing.has_value()); + CHECK(missing->isEmpty()); + + makeMod(QDir(game.path()).filePath(QStringLiteral("Data")), + QStringLiteral("ludeon.rimworld"), QStringLiteral("ludeon.rimworld"), + false); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + CHECK(discovered->constBegin().value()->name == QStringLiteral("Core")); +} + +TEST_CASE("cached metadata is returned without parsing") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString plain = makeMod(root.path(), QStringLiteral("plain"), + QStringLiteral("cached.mod"), false); + + FakeCache cache; + cache.serveCached = true; + cache.cachedMod = std::make_shared(); + REQUIRE(cache.cachedMod->setModPath(plain)); + + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->size() == 1); + CHECK(discovered->constBegin().value() == cache.cachedMod); + CHECK(cache.cachedMod->providerId == QStringLiteral("local")); + CHECK(cache.stored.isEmpty()); +} + +TEST_CASE("newly parsed About.xml data is stored in the cache") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString plain = makeMod(root.path(), QStringLiteral("plain"), + QStringLiteral("parsed.mod"), false); + + FakeCache cache; + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = + runToCompletion(provider.discover(QStringLiteral("1.5"))); + REQUIRE(discovered.has_value()); + CHECK(discovered->contains(plain)); + + REQUIRE(cache.stored.size() == 1); + CHECK(cache.stored.first().candidate.modPath == plain); + CHECK(cache.stored.first().mod->valid); +} + +TEST_CASE("force_reparse bypasses the cache lookup") { + QTemporaryDir root; + REQUIRE(root.isValid()); + makeMod(root.path(), QStringLiteral("plain"), QStringLiteral("forced.mod"), + false); + + FakeCache cache; + cache.serveCached = true; + cache.cachedMod = std::make_shared(); + + LocalModProvider provider(root.path(), nullptr, &cache); + const auto discovered = runToCompletion(provider.discover( + QStringLiteral("1.5"), nullptr, nullptr, /*force_reparse=*/true)); + REQUIRE(discovered.has_value()); + CHECK(cache.getCalls == 0); + CHECK(discovered->size() == 1); + CHECK(discovered->constBegin().value() != cache.cachedMod); +} diff --git a/tests/core/services/services_support.hpp b/tests/core/services/services_support.hpp new file mode 100644 index 0000000..c950655 --- /dev/null +++ b/tests/core/services/services_support.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace services_test { + +// Owns a QCoreApplication for one test case, unless one already exists +// (test_providers keeps a process-wide instance, and Qt asserts when a second +// one is created). +class EventLoop { + public: + EventLoop() { + if (!QCoreApplication::instance()) { + m_app.emplace(m_argc, nullptr); + } + } + + private: + int m_argc = 0; + std::optional m_app; +}; + +// Runs a coroutine to completion on the event loop. Coroutines that never +// suspend finish before the loop starts. +template +T runAsync(QCoro::Task task) { + // std::optional is ill-formed, so a void result is carried as + // std::monostate. + using Outcome = std::conditional_t, std::monostate, T>; + std::optional result; + auto driver = [](QCoro::Task inner, + std::optional* out) -> QCoro::Task { + if constexpr (std::is_void_v) { + co_await std::move(inner); + out->emplace(); + } else { + *out = co_await std::move(inner); + } + QCoreApplication::quit(); + }; + driver(std::move(task), &result); + if (!result) { + QCoreApplication::exec(); + } + REQUIRE(result.has_value()); + if constexpr (std::is_void_v) { + return; + } else { + return std::move(*result); + } +} + +inline bool writeText(const QString& path, const QByteArray& bytes) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + return file.write(bytes) == bytes.size(); +} + +} // namespace services_test diff --git a/tests/core/services/startup_impact_service/test_db.cpp b/tests/core/services/startup_impact_service/test_db.cpp new file mode 100644 index 0000000..9fa21ed --- /dev/null +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -0,0 +1,306 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include "../../test_support.hpp" +#include "core/services/startup_impact_service/db.hpp" + +using namespace px::modrim::startup_impact; +using Catch::Matchers::WithinAbs; +using px::modrim::Result; +using test_support::TestApplication; + +namespace { + +// Awaits a DB call on the event loop and fails the test on error. +template +auto ok(QCoro::Task> task) { + auto result = QCoro::waitFor(std::move(task)); + if (!result.has_value()) { + UNSCOPED_INFO("DB error: " << result.error().message.toStdString()); + } + REQUIRE(result.has_value()); + if constexpr (std::is_void_v) { + return; + } else { + return std::move(*result); + } +} + +StartupImpactMod modEntry(const QString& name, + const std::optional& pid, double totalS, + double offS = 0.0) { + StartupImpactMod mod; + mod.modName = name; + mod.packageId = pid; + mod.totalImpactS = totalS; + mod.offThreadTotalImpactS = offS; + return mod; +} + +StartupImpactReport makeReport(const QString& pid, double totalS, + double offThreadS = 0.0) { + StartupImpactReport report; + report.loadingTimeS = 1.0; + report.timestamp = QStringLiteral("now"); + report.mods.append( + modEntry(QStringLiteral("Mod ") + pid, pid, totalS, offThreadS)); + return report; +} + +QString dbFile(const QTemporaryDir& dir) { + return QDir(dir.path()).filePath(QStringLiteral("cache.db")); +} + +} // namespace + +TEST_CASE("store and clear run in the order they were issued") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + // Both calls are issued before either is awaited. The single worker runs them + // in order. + const auto report = makeReport(QStringLiteral("author.example"), 0.5); + auto stored = db.storeReport(path, report); + auto cleared = db.clear(path); + REQUIRE(QCoro::waitFor(std::move(stored)).has_value()); + REQUIRE(QCoro::waitFor(std::move(cleared)).has_value()); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + CHECK(latest->mods[0].packageId == QStringLiteral("author.example")); +} + +TEST_CASE("base game keeps its sentinel package id after a round trip") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report; + report.loadingTimeS = 1.0; + report.timestamp = QStringLiteral("now"); + report.mods.append(modEntry(QStringLiteral("Example"), + QStringLiteral("author.example"), 0.5)); + report.mods.append( + modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + + QHash> byName; + for (const StartupImpactMod& mod : latest->mods) { + byName.insert(mod.modName, mod.packageId); + } + REQUIRE(byName.value(QString(kBaseGamePid)) == QString(kBaseGamePid)); +} + +TEST_CASE("a mod stored without a package id reads back without one") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report; + report.loadingTimeS = 1.0; + report.mods.append(modEntry(QStringLiteral("No Package"), std::nullopt, 0.2)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + CHECK_FALSE(latest->mods[0].packageId.has_value()); + CHECK(latest->mods[0].modName == QStringLiteral("No Package")); +} + +TEST_CASE("metrics survive a round trip") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report; + report.loadingTimeS = 3.0; + StartupImpactMod harmony = modEntry( + QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"), 1.25, 0.3); + harmony.metrics.insert(QStringLiteral("ModConstructor"), 1.0); + harmony.offThreadMetrics.insert(QStringLiteral("LoadPatches"), 0.3); + report.mods.append(std::move(harmony)); + ok(db.storeReport(path, report)); + const auto latest = ok(db.getLatestReport(path)); + REQUIRE(latest.has_value()); + REQUIRE(latest->mods.size() == 1); + const StartupImpactMod& mod = latest->mods[0]; + CHECK(mod.totalImpactS == Catch::Approx(1.25)); + CHECK(mod.metrics.value(QStringLiteral("ModConstructor")) == + Catch::Approx(1.0)); + CHECK(mod.offThreadMetrics.value(QStringLiteral("LoadPatches")) == + Catch::Approx(0.3)); + CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.3)); + CHECK(latest->loadingTimeS == Catch::Approx(3.0)); +} + +TEST_CASE("averages weight the newest session most") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + const QString pid = QStringLiteral("a.a"); + + ok(db.storeReport(path, makeReport(pid, 1.0, 0.5))); + ok(db.storeReport(path, makeReport(pid, 3.0, 1.5))); + + // Older session weighs 9, newest weighs 10: (1.0*9 + 3.0*10) / 19 and (0.5*9 + // + 1.5*10) / 19. + CHECK_THAT(ok(db.getAverage(path, pid)), WithinAbs(39.0 / 19.0, 1e-9)); + const auto totals = ok(db.getTotalsFor(path, {pid})); + REQUIRE(totals.contains(pid)); + CHECK_THAT(totals.value(pid).onThread, WithinAbs(39.0 / 19.0, 1e-9)); + CHECK_THAT(totals.value(pid).offThread, WithinAbs(19.5 / 19.0, 1e-9)); + CHECK(ok(db.getAllAverages(path)).value(pid) == Catch::Approx(39.0 / 19.0)); +} + +TEST_CASE("only the most recent sessions contribute to averages") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + ok(db.storeReport(path, makeReport(QStringLiteral("old.mod"), 100.0))); + for (int i = 0; i < 5; ++i) { + ok(db.storeReport(path, makeReport(QStringLiteral("new.mod"), 1.0))); + } + + // Five sessions are kept, so the old one is gone from history entirely. + CHECK(ok(db.getAverage(path, QStringLiteral("old.mod"))) == 0.0); + CHECK_THAT(ok(db.getAverage(path, QStringLiteral("new.mod"))), + WithinAbs(1.0, 1e-9)); + CHECK_FALSE(ok(db.getAllAverages(path)).contains(QStringLiteral("old.mod"))); +} + +TEST_CASE("snapshot returns the report with averages for the requested mods") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + StartupImpactReport report = makeReport(QStringLiteral("a.a"), 1.0, 0.2); + report.mods.append( + modEntry(QString(kBaseGamePid), QString(kBaseGamePid), 2.0)); + ok(db.storeReport(path, report)); + + const auto snap = ok(db.getLatestWithAverages( + path, {QStringLiteral("a.a"), QStringLiteral("missing.mod")}, + QStringLiteral("a.a"))); + REQUIRE(snap.report.has_value()); + CHECK(snap.report->mods.size() == 2); + CHECK_THAT(snap.baseGameAvg, WithinAbs(2.0, 1e-9)); + CHECK_THAT(snap.selectedAvg, WithinAbs(1.0, 1e-9)); + REQUIRE(snap.totals.contains(QStringLiteral("a.a"))); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).onThread, + WithinAbs(1.0, 1e-9)); + CHECK_THAT(snap.totals.value(QStringLiteral("a.a")).offThread, + WithinAbs(0.2, 1e-9)); + REQUIRE(snap.totals.contains(QStringLiteral("missing.mod"))); + CHECK(snap.totals.value(QStringLiteral("missing.mod")).onThread == 0.0); +} + +TEST_CASE( + "reads of a missing database return empty results and create no file") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + CHECK(ok(db.getAverage(path, QStringLiteral("a.a"))) == 0.0); + CHECK(ok(db.getAllAverages(path)).isEmpty()); + + const auto snap = + ok(db.getLatestWithAverages(path, {QStringLiteral("a.a")}, {})); + CHECK_FALSE(snap.report.has_value()); + CHECK(snap.totals.value(QStringLiteral("a.a")).onThread == 0.0); + CHECK_FALSE(QFileInfo::exists(path)); +} + +TEST_CASE("store creates a missing parent directory") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = + QDir(dir.path()).filePath(QStringLiteral("nested/config/cache.db")); + + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); + CHECK(QFileInfo::exists(path)); +} + +TEST_CASE("clear removes all history") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); + ok(db.clear(path)); + CHECK_FALSE(ok(db.getLatestReport(path)).has_value()); + CHECK(ok(db.getAllAverages(path)).isEmpty()); +} + +TEST_CASE("the database uses the legacy schema and version") { + TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + StartupImpactDb db; + const QString path = dbFile(dir); + ok(db.storeReport(path, makeReport(QStringLiteral("a.a"), 1.0))); + + // A second connection reads what the first wrote, as the legacy app would. + const QString name = QStringLiteral("startup_impact_schema_check"); + { + QSqlDatabase verify = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + verify.setDatabaseName(path); + REQUIRE(verify.open()); + + QSqlQuery version(verify); + REQUIRE(version.exec(QStringLiteral("PRAGMA user_version"))); + REQUIRE(version.next()); + CHECK(version.value(0).toInt() == 1); + + QSqlQuery tables(verify); + REQUIRE( + tables.exec(QStringLiteral("SELECT name FROM sqlite_master WHERE type " + "= 'table' AND name LIKE 'startup_impact_%'" + " ORDER BY name"))); + QStringList names; + while (tables.next()) { + names.append(tables.value(0).toString()); + } + CHECK(names == QStringList{QStringLiteral("startup_impact_entries"), + QStringLiteral("startup_impact_sessions")}); + } + QSqlDatabase::removeDatabase(name); +} diff --git a/tests/core/services/startup_impact_service/test_phases.cpp b/tests/core/services/startup_impact_service/test_phases.cpp new file mode 100644 index 0000000..5020a5c --- /dev/null +++ b/tests/core/services/startup_impact_service/test_phases.cpp @@ -0,0 +1,88 @@ +#include +#include + +#include +#include + +#include "core/services/startup_impact_service/labels.hpp" +#include "core/services/startup_impact_service/phases.hpp" + +using namespace px::modrim::startup_impact; +using Catch::Matchers::WithinAbs; + +namespace { + +const QString kPrefix = QStringLiteral("LoadingProgress.StartupImpact."); + +} // namespace + +TEST_CASE("metric_phase assigns each metric key to its phase") { + struct Case { + const char* key; + Phase phase; + }; + const Case cases[] = { + {"ModContentPackReloadContentInt.Textures", Phase::kContent}, + {"GlobalTextureAtlasManagerBakeStaticAtlases", Phase::kContent}, + {"ModConstructor", Phase::kModConstructors}, + {"StaticConstructorOnStartupUtilityCallAll", Phase::kStaticConstructors}, + {"ApplyPatches", Phase::kPatches}, + {"ErrorCheckPatches", Phase::kPatches}, + {"LoadDefs", Phase::kDefs}, + {"ResolveAllWantedCrossReferences.NonImplied", Phase::kDefs}, + {"ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", + Phase::kDeferred}, + {"LanguageDatabaseInitAllMetadata", Phase::kOther}, + {"SomethingNew", Phase::kOther}, + }; + for (const Case& c : cases) { + INFO(c.key); + CHECK(metricPhase(kPrefix + QString::fromLatin1(c.key)) == c.phase); + } +} + +TEST_CASE("metric_phase also accepts keys without the prefix") { + CHECK(metricPhase(QStringLiteral("LoadDefs")) == Phase::kDefs); +} + +TEST_CASE("phase_totals sums per phase and zero-fills the rest") { + const QMap metrics{ + {kPrefix + QStringLiteral("ApplyPatches"), 1.5}, + {kPrefix + QStringLiteral("LoadPatches"), 0.5}, + {kPrefix + QStringLiteral("LoadDefs"), 2.0}, + }; + const PhaseTotals totals = phaseTotals(metrics); + CHECK_THAT(totals[phaseIndex(Phase::kPatches)], WithinAbs(2.0, 1e-9)); + CHECK_THAT(totals[phaseIndex(Phase::kDefs)], WithinAbs(2.0, 1e-9)); + CHECK(totals[phaseIndex(Phase::kContent)] == 0.0); + CHECK(totals.size() == kPhaseCount); +} + +TEST_CASE("every phase has a label") { + for (std::size_t i = 0; i < kPhaseCount; ++i) { + CHECK_FALSE(phaseLabel(static_cast(i)).isEmpty()); + } + CHECK(phaseLabel(Phase::kDefs) == QStringLiteral("Defs & XML")); +} + +TEST_CASE("metric_label names a deferred task's target") { + const QString key = kPrefix + QStringLiteral( + "ExecuteToExecuteWhenFinished|RimWorld." + "AbilityDef -> Void b__88_0()"); + CHECK(metricLabel(key) == + QStringLiteral( + "Running delayed initialization task (RimWorld.AbilityDef)")); +} + +TEST_CASE( + "metric_label falls back to the last key segment, then the key itself") { + CHECK(metricLabel(kPrefix + QStringLiteral( + "ModContentPackReloadContentInt.Textures")) == + QStringLiteral("Loading textures")); + CHECK(metricLabel(kPrefix + QStringLiteral("Some.Unknown.LoadDefs")) == + QStringLiteral("Loading defs")); + CHECK(metricLabel(kPrefix + QStringLiteral("Totally.New")) == + QStringLiteral("Totally.New")); + CHECK(metricLabel(QStringLiteral("ModConstructor")) == + QStringLiteral("Running mod constructor")); +} diff --git a/tests/core/services/startup_impact_service/test_startup_impact.cpp b/tests/core/services/startup_impact_service/test_startup_impact.cpp new file mode 100644 index 0000000..3fd1d94 --- /dev/null +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -0,0 +1,315 @@ +#include +#include +#include + +#include +#include +#include + +#include "../../test_support.hpp" +#include "core/services/startup_impact_service/models.hpp" +#include "core/services/startup_impact_service/parser.hpp" + +using namespace px::modrim::startup_impact; +using Catch::Matchers::WithinAbs; +using test_support::writeBytes; + +namespace { + +constexpr double kEps = 1e-9; + +const char* kSampleJson = R"({ + "timestamp": "2026-07-16T14:30:45.123Z", + "loadingTime": 42500.5, + "metrics": { + "LoadModXml": 10200.0, + "ResolveReferences": 1500.0 + }, + "totalImpact": 0.0, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0, + "mods": [ + { + "modName": "Harmony", + "modPackageId": "brrainz.harmony", + "metrics": { + "ModConstructor": 1000.0 + }, + "totalImpact": 1230.5, + "offThreadMetrics": { + "LoadPatches": 300.0 + }, + "offThreadTotalImpact": 300.0 + }, + { + "modName": "Some Mod", + "modPackageId": "author.somemod", + "metrics": { + "LoadModXml": 4500.0 + }, + "totalImpact": 4500.0, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0 + }, + { + "modName": "Fast Mod", + "modPackageId": "author.fast", + "metrics": {}, + "totalImpact": 50.0, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0 + }, + { + "modName": "No Package ID Mod", + "totalImpact": 200.0, + "metrics": {}, + "offThreadMetrics": {}, + "offThreadTotalImpact": 0.0 + } + ] +} +)"; + +// Writes `json` to a file in `dir` and parses it. +std::optional parseText(const QTemporaryDir& dir, + const QString& name, + const char* json) { + const QString path = QDir(dir.path()).filePath(name); + REQUIRE(writeBytes(path, json)); + return parseStartupImpact(path); +} + +} // namespace + +TEST_CASE("normalize_package_id lowercases and strips the steam suffix") { + CHECK(normalizePackageId(QStringLiteral("Author.Mod")) == + QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("author.mod_steam")) == + QStringLiteral("author.mod")); + CHECK(normalizePackageId(QStringLiteral("author.mod")) == + QStringLiteral("author.mod")); +} + +TEST_CASE("format_impact rounds seconds to whole milliseconds") { + CHECK(formatImpact(1.234) == QStringLiteral("1234ms")); + CHECK(formatImpact(0.05) == QStringLiteral("50ms")); + CHECK(formatImpact(0.0) == QStringLiteral("0ms")); +} + +TEST_CASE("format_impact rounds half to even like Python's round") { + CHECK(formatImpact(0.0005) == QStringLiteral("0ms")); + CHECK(formatImpact(0.0015) == QStringLiteral("2ms")); + CHECK(formatImpact(0.0025) == QStringLiteral("2ms")); +} + +TEST_CASE("get_startup_impact_path sits beside the config folder") { + CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config")) == + QStringLiteral("/home/user/StartupImpactData.json")); + CHECK(getStartupImpactPath(QStringLiteral("/home/user/Config/")) == + QStringLiteral("/home/user/StartupImpactData.json")); + CHECK(getStartupImpactPath(QStringLiteral("/Config")) == + QStringLiteral("/StartupImpactData.json")); +} + +TEST_CASE("parse_startup_impact reads the full report") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + CHECK(report->loadingTimeS == Catch::Approx(42.5005)); + CHECK(report->timestamp == QStringLiteral("2026-07-16T14:30:45.123Z")); + REQUIRE(report->metrics.size() == 2); + CHECK(report->metrics.value(QStringLiteral("LoadModXml")) == + Catch::Approx(10.2)); + CHECK(report->metrics.value(QStringLiteral("ResolveReferences")) == + Catch::Approx(1.5)); + CHECK(report->totalImpact == 0.0); + CHECK(report->mods.size() == 4); +} + +TEST_CASE("base game time is the sum of the base-game metrics") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + // Not loading time minus mod impacts: loading time includes unaccounted time. + CHECK_THAT(report->baseGameTime(), WithinAbs(11.7, kEps)); + CHECK(std::abs(report->loadingTimeS - report->totalTime()) > 1.0); +} + +TEST_CASE("mods_total_time includes off-thread impact") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + const StartupImpactMod& harmony = report->mods[0]; + CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); + CHECK(harmony.offThreadTotalImpactS == Catch::Approx(0.3)); + // total_time counts on-thread impact only; mods_total_time adds off-thread + // impact. + CHECK_THAT(report->totalTime(), WithinAbs(1.2305 + 4.5 + 0.05 + 0.2, kEps)); + CHECK_THAT(report->offThreadTotalTime(), WithinAbs(0.3, kEps)); + CHECK_THAT(report->modsTotalTime(), + WithinAbs(report->totalTime() + 0.3, kEps)); +} + +TEST_CASE("parse_startup_impact reads mod values") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + + const StartupImpactMod& harmony = report->mods[0]; + CHECK(harmony.modName == QStringLiteral("Harmony")); + CHECK(harmony.packageId == QStringLiteral("brrainz.harmony")); + CHECK(harmony.totalImpactS == Catch::Approx(1.2305)); + CHECK(harmony.metrics.value(QStringLiteral("ModConstructor")) == + Catch::Approx(1.0)); + + const StartupImpactMod& someMod = report->mods[1]; + CHECK(someMod.modName == QStringLiteral("Some Mod")); + CHECK(someMod.packageId == QStringLiteral("author.somemod")); + CHECK(someMod.totalImpactS == Catch::Approx(4.5)); + CHECK(someMod.metrics.value(QStringLiteral("LoadModXml")) == + Catch::Approx(4.5)); + + const StartupImpactMod& fast = report->mods[2]; + CHECK(fast.modName == QStringLiteral("Fast Mod")); + CHECK(fast.packageId == QStringLiteral("author.fast")); + CHECK(fast.totalImpactS == Catch::Approx(0.05)); + CHECK(fast.metrics.isEmpty()); +} + +TEST_CASE("a mod without a package id has none after parsing") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = + parseText(dir, QStringLiteral("StartupImpactData.json"), kSampleJson); + REQUIRE(report.has_value()); + + const StartupImpactMod& noPid = report->mods[3]; + CHECK(noPid.modName == QStringLiteral("No Package ID Mod")); + CHECK_FALSE(noPid.packageId.has_value()); + CHECK(noPid.totalImpactS == Catch::Approx(0.2)); + CHECK(noPid.metrics.isEmpty()); +} + +TEST_CASE("parse_startup_impact returns nullopt for a missing file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE( + parseStartupImpact(QDir(dir.path()).filePath(QStringLiteral("nope.json"))) + .has_value()); +} + +TEST_CASE("parse_startup_impact reads an empty mod list") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("empty_mods.json"), + R"({"loadingTime": 1000.0, "mods": []})"); + REQUIRE(report.has_value()); + CHECK(report->loadingTimeS == Catch::Approx(1.0)); + CHECK(report->mods.isEmpty()); +} + +TEST_CASE("parse_startup_impact rejects a top-level array") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE( + parseText(dir, QStringLiteral("not_a_dict.json"), "[]").has_value()); +} + +TEST_CASE("parse_startup_impact rejects malformed JSON") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + CHECK_FALSE( + parseText(dir, QStringLiteral("bad.json"), "{invalid").has_value()); +} + +TEST_CASE("parse_startup_impact accepts an object without known keys") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("invalid.json"), + R"({"wrongKey": "value"})"); + REQUIRE(report.has_value()); + CHECK(report->mods.isEmpty()); +} + +TEST_CASE("total_time sums on-thread mod impact") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("total.json"), R"({ + "loadingTime": 50000.0, + "mods": [ + {"modName": "A", "modPackageId": "a.a", + "totalImpact": 2000.0, "metrics": {}, + "offThreadMetrics": {}, "offThreadTotalImpact": 0.0}, + {"modName": "B", "modPackageId": "b.b", + "totalImpact": 3000.0, "metrics": {}, + "offThreadMetrics": {}, "offThreadTotalImpact": 0.0} + ] + })"); + REQUIRE(report.has_value()); + CHECK(report->totalTime() == Catch::Approx(5.0)); +} + +TEST_CASE("total_time of an empty report is zero") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("total_empty.json"), + R"({"loadingTime": 1000.0, "mods": []})"); + REQUIRE(report.has_value()); + CHECK(report->totalTime() == 0.0); +} + +TEST_CASE("parse_startup_impact reads off-thread mod fields") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto report = parseText(dir, QStringLiteral("offthread.json"), R"({ + "loadingTime": 10000.0, + "mods": [ + { + "modName": "Test Mod", + "modPackageId": "author.test", + "totalImpact": 2000.0, + "metrics": {"A": 1000.0, "B": 1000.0}, + "offThreadMetrics": {"C": 500.0}, + "offThreadTotalImpact": 500.0 + } + ] + })"); + REQUIRE(report.has_value()); + const StartupImpactMod& mod = report->mods[0]; + REQUIRE(mod.offThreadMetrics.size() == 1); + CHECK(mod.offThreadMetrics.value(QStringLiteral("C")) == Catch::Approx(0.5)); + CHECK(mod.offThreadTotalImpactS == Catch::Approx(0.5)); +} + +TEST_CASE("find matches by package id, then by mod name") { + StartupImpactReport report; + StartupImpactMod m1; + m1.modName = QStringLiteral("Harmony"); + m1.packageId = QStringLiteral("brrainz.harmony"); + report.mods.append(std::move(m1)); + StartupImpactMod m2; + m2.modName = QStringLiteral("Other"); + m2.packageId = QStringLiteral("other.mod"); + report.mods.append(std::move(m2)); + + const StartupImpactMod* byId = + report.find(QStringLiteral("Brrainz.Harmony_steam"), {}); + REQUIRE(byId != nullptr); + CHECK(byId->modName == QStringLiteral("Harmony")); + + const StartupImpactMod* byName = report.find({}, QStringLiteral("OTHER")); + REQUIRE(byName != nullptr); + CHECK(byName->packageId == QStringLiteral("other.mod")); + + CHECK(report.find(QStringLiteral("missing.mod"), QStringLiteral("Missing")) == + nullptr); + CHECK(report.find(QString(), QString()) == nullptr); +} diff --git a/tests/core/services/test_activation_service.cpp b/tests/core/services/test_activation_service.cpp new file mode 100644 index 0000000..4c0df35 --- /dev/null +++ b/tests/core/services/test_activation_service.cpp @@ -0,0 +1,387 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/models/view/sidebar.hpp" +#include "core/services/activation_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "services_support.hpp" + +using namespace px::modrim; + +namespace { + +using Mods = QHash; + +QStringList ids(std::initializer_list items) { + QStringList result; + for (const char* item : items) { + result.append(QString::fromLatin1(item)); + } + return result; +} + +// About.xml mod with the given rules. Each dependency is a bare package id. +ListedModPtr makeMod(const char* name, const char* pid, + std::initializer_list deps = {}, + std::initializer_list loadAfter = {}, + std::initializer_list loadBefore = {}) { + auto mod = std::make_shared(); + mod->name = QString::fromLatin1(name); + mod->packageId = CaseInsensitiveStr(QString::fromLatin1(pid)); + mod->providerId = QStringLiteral("stub"); + mod->valid = true; + mod->aboutRules.loadAfter = CaseInsensitiveSet(ids(loadAfter)); + mod->aboutRules.loadBefore = CaseInsensitiveSet(ids(loadBefore)); + for (const char* dep : deps) { + DependencyMod dependency; + dependency.name = QString::fromLatin1(dep); + dependency.packageId = CaseInsensitiveStr(QString::fromLatin1(dep)); + mod->aboutRules.setDependency(dependency); + } + return mod; +} + +Mods baseMods() { + Mods mods; + mods.insert("uuid-a", makeMod("Mod A", "mod.a")); + mods.insert("uuid-b", makeMod("Mod B", "mod.b", {"mod.a"})); + mods.insert("uuid-c", makeMod("Mod C", "mod.c", {"mod.b"})); + mods.insert("uuid-d", makeMod("Mod D", "mod.d")); + mods.insert("uuid-e", makeMod("Mod E", "mod.e", {"mod.a"})); + mods.insert("uuid-f", makeMod("Mod F", "mod.f")); + mods.insert("uuid-g", makeMod("Mod G", "mod.g", {"mod.f"})); + mods.insert("uuid-h", makeMod("Mod H", "mod.h", {}, {"mod.b"}, {"mod.a"})); + mods.insert("uuid-i", makeMod("Mod I", "mod.i", {}, {"mod.d"})); + mods.insert("uuid-j", makeMod("Mod J", "mod.j", {}, {}, {"mod.a"})); + return mods; +} + +// Owns the config directory and the context for one test. Starts with the base +// mods loaded as c, d, a, b and the diagnostics rebuilt. +struct Fixture { + services_test::EventLoop loop; + QTemporaryDir dir; + ConfigService configService{dir.path()}; + std::unique_ptr ctx = + CoreContext::create(AppConfig{}, &configService); + + Fixture() { + ctx->load(baseMods(), ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + ctx->diagnosticsService().rebuild(); + } + + void reload(Mods mods, std::initializer_list active) { + ctx->load(std::move(mods), ids(active)); + ctx->diagnosticsService().rebuild(); + } +}; + +} // namespace + +TEST_CASE( + "enabling inserts after dependency without reordering existing mods") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect( + [&emitted](const QStringList& uuids) { emitted.push_back(uuids); }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}))); + + const QStringList expected = + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); +} + +TEST_CASE("unconstrained enable appends") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); +} + +TEST_CASE("enabling mod with load-after rule inserts after target") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-i"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-i", "uuid-a", "uuid-b"})); +} + +TEST_CASE("enabling mod with load-before rule inserts before target") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-j"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-j", "uuid-a", "uuid-b"})); +} + +TEST_CASE("enabling dependency before active dependent") { + Fixture f; + f.reload(baseMods(), {"uuid-b", "uuid-c", "uuid-d"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-a"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c", "uuid-d"})); +} + +TEST_CASE("conflicting constraints fall back to appending") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-h"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-h"})); +} + +TEST_CASE("batch enables respect dependency order") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-g", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f", "uuid-g"})); +} + +TEST_CASE("enabling already active mod keeps its position") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-c", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); +} + +TEST_CASE("disabling removes and keeps remaining order") { + Fixture f; + + REQUIRE(f.ctx->activation().apply({}, ids({"uuid-d", "uuid-a"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-b"})); +} + +TEST_CASE("unknown ids are ignored") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-missing", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"})); +} + +TEST_CASE("mixed selection applies in a single emission") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect( + [&emitted](const QStringList& uuids) { emitted.push_back(uuids); }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e"}), + ids({"uuid-a", "uuid-d"}))); + + const QStringList expected = ids({"uuid-c", "uuid-b", "uuid-f", "uuid-e"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); +} + +TEST_CASE("uuid in both enable and disable favors disable") { + Fixture f; + std::vector emitted; + f.ctx->activeStateChanged().connect( + [&emitted](const QStringList& uuids) { emitted.push_back(uuids); }); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-a", "uuid-f", "uuid-e"}), + ids({"uuid-a", "uuid-f"}))); + + const QStringList expected = ids({"uuid-c", "uuid-d", "uuid-b", "uuid-e"}); + CHECK(f.ctx->activeUuids() == expected); + REQUIRE(emitted.size() == 1); + CHECK(emitted.front() == expected); +} + +TEST_CASE("duplicate enable is deduped in input order") { + Fixture f; + + REQUIRE(f.ctx->activation().apply(ids({"uuid-f", "uuid-e", "uuid-f"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"})); +} + +TEST_CASE("no-op returns false without emitting") { + const std::vector> cases{ + {ids({"uuid-a", "uuid-c"}), {}}, + {{}, ids({"uuid-e", "uuid-f"})}, + {ids({"uuid-missing"}), {}}, + {{}, ids({"uuid-missing"})}, + {{}, {}}, + {ids({"uuid-missing"}), ids({"uuid-missing"})}, + }; + for (const auto& [enable, disable] : cases) { + Fixture f; + int emissions = 0; + f.ctx->activeStateChanged().connect( + [&emissions](const QStringList&) { ++emissions; }); + + CHECK_FALSE(f.ctx->activation().apply(enable, disable)); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-a", "uuid-b"})); + CHECK(emissions == 0); + } +} + +TEST_CASE("edges through an uninstalled mod add no constraint") { + Fixture f; + Mods mods; + mods.insert("uuid-a", makeMod("Mod A", "mod.a")); + mods.insert("uuid-w", makeMod("Mod W", "mod.w", {}, {"ce.absent"})); + mods.insert("uuid-y", makeMod("Mod Y", "mod.y", {}, {}, {"ce.absent"})); + f.reload(mods, {"uuid-a", "uuid-w"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-y"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-w", "uuid-y"})); +} + +TEST_CASE("bounds ignore an inconsistent active order") { + Fixture f; + Mods mods = baseMods(); + // Active c depends on b but sits before it; X must still land in d..b. + mods.insert("uuid-x", makeMod("Mod X", "mod.x", {"mod.d"}, {}, {"mod.b"})); + f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); + + REQUIRE(f.ctx->activation().apply(ids({"uuid-x"}))); + + CHECK(f.ctx->activeUuids() == + ids({"uuid-c", "uuid-d", "uuid-x", "uuid-a", "uuid-b"})); +} + +TEST_CASE("batch cycle does not unplace downstream mods") { + Fixture f; + Mods mods = baseMods(); + mods.insert("uuid-p", makeMod("Mod P", "mod.p", {"mod.q"})); + mods.insert("uuid-q", makeMod("Mod Q", "mod.q", {"mod.p"})); + mods.insert("uuid-z", makeMod("Mod Z", "mod.z", {"mod.p"})); + mods.insert("uuid-k", makeMod("Mod K", "mod.k", {"mod.d"}, {}, {"mod.a"})); + f.reload(mods, {"uuid-c", "uuid-d", "uuid-a", "uuid-b"}); + + REQUIRE( + f.ctx->activation().apply(ids({"uuid-z", "uuid-k", "uuid-q", "uuid-p"}))); + + CHECK(f.ctx->activeUuids() == ids({"uuid-c", "uuid-d", "uuid-k", "uuid-a", + "uuid-b", "uuid-q", "uuid-p", "uuid-z"})); +} + +TEST_CASE("dependents_of lists transitive dependents in active order") { + Fixture f; + + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == + ids({"uuid-c", "uuid-b"})); +} + +TEST_CASE("dependents_of excludes its inputs") { + Fixture f; + + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a", "uuid-b"})) == + ids({"uuid-c"})); +} + +TEST_CASE("dependents_of excludes inactive dependents") { + Fixture f; + + CHECK_FALSE(f.ctx->activeUuids().contains(QStringLiteral("uuid-e"))); + CHECK_FALSE(f.ctx->activation() + .dependentsOf(ids({"uuid-a"})) + .contains(QStringLiteral("uuid-e"))); +} + +TEST_CASE("dependents_of reflects the graph after a state change") { + Fixture f; + REQUIRE(f.ctx->activation().apply(ids({"uuid-e"}))); + + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == + ids({"uuid-c", "uuid-e", "uuid-b"})); +} + +TEST_CASE("dependents_of is empty for a mod without dependents") { + Fixture f; + + CHECK(f.ctx->activation() + .dependentsOf(ids({"uuid-d", "uuid-missing"})) + .isEmpty()); +} + +TEST_CASE("removing a duplicate copy keeps dependents") { + Fixture f; + Mods mods = baseMods(); + mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); + f.reload(mods, {"uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"}); + + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a-copy"})).isEmpty()); + REQUIRE(f.ctx->activation().apply({}, ids({"uuid-a-copy"}))); + CHECK(f.ctx->activeUuids() == ids({"uuid-a", "uuid-b", "uuid-c"})); + CHECK(f.ctx->activation().dependentsOf(ids({"uuid-a"})) == + ids({"uuid-b", "uuid-c"})); +} + +TEST_CASE("enabling a replacement copy keeps dependents") { + Fixture f; + Mods mods = baseMods(); + mods.insert("uuid-a-copy", makeMod("Copy A", "mod.a")); + f.reload(mods, {"uuid-a", "uuid-b", "uuid-c"}); + + CHECK(f.ctx->activation() + .dependentsOf(ids({"uuid-a"}), ids({"uuid-a-copy"})) + .isEmpty()); + REQUIRE(f.ctx->activation().apply(ids({"uuid-a-copy"}), ids({"uuid-a"}))); + CHECK(f.ctx->activeUuids() == ids({"uuid-a-copy", "uuid-b", "uuid-c"})); +} + +TEST_CASE("sidebar entries are published before the summary") { + // The summary handler re-applies the sidebar filter, so entries must be + // fresh. + Fixture f; + DiagnosticsService& diagnostics = f.ctx->diagnosticsService(); + QSet inactive; + std::vector> seenAtSummary; + + diagnostics.sidebarEntriesChanged.connect( + [&inactive](const QList& entries) { + inactive.clear(); + for (const view::SidebarEntry& entry : entries) { + if (entry.label == QStringLiteral("Inactive")) { + inactive = entry.visibleUuids; + } + } + }); + diagnostics.diagnosticsSummaryChanged.connect( + [&inactive, + &seenAtSummary](const QHash&) { + seenAtSummary.push_back(inactive); + }); + + diagnostics.rebuild(f.ctx->activeUuids()); + seenAtSummary.clear(); + REQUIRE(f.ctx->activation().apply(ids({"uuid-f"}))); + + REQUIRE_FALSE(seenAtSummary.empty()); + CHECK_FALSE(seenAtSummary.back().contains(QStringLiteral("uuid-f"))); +} diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp new file mode 100644 index 0000000..d10de4d --- /dev/null +++ b/tests/core/services/test_game_launcher.cpp @@ -0,0 +1,308 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/config.hpp" +#include "core/constants.hpp" +#include "core/context.hpp" +#include "core/services/game_launcher.hpp" +#include "core/services/game_process.hpp" +#include "services_support.hpp" + +using px::modrim::AppConfig; +using px::modrim::CoreContext; +using px::modrim::LaunchStrategy; +using px::modrim::PathConfig; +using px::modrim::Result; +using px::modrim::services::buildDirectCommand; +using px::modrim::services::findExecutable; +using px::modrim::services::GameLauncher; +using px::modrim::services::GameProcessMonitor; +using px::modrim::services::isGameRunning; +using px::modrim::services::MonitorTiming; +using px::modrim::services::Platform; +using px::modrim::services::ProcessInfo; +using px::modrim::services::ProcessLister; +using px::modrim::services::splitArguments; +using px::modrim::services::steamRunUrl; +using services_test::EventLoop; +using services_test::runAsync; + +namespace { + +ProcessInfo proc(const QString& name, qint64 pid = 1, bool zombie = false) { + return ProcessInfo{pid, name, zombie}; +} + +struct Replay { + std::vector> snapshots; + std::size_t next = 0; +}; + +// Lister that returns the snapshots in order, then keeps returning the last +// one. +ProcessLister replay(std::vector> snapshots) { + auto state = std::make_shared(Replay{std::move(snapshots), 0}); + return [state] { + const std::size_t last = state->snapshots.size() - 1; + const auto snapshot = state->snapshots[std::min(state->next, last)]; + if (state->next < last) { + ++state->next; + } + return snapshot; + }; +} + +const MonitorTiming kNoSleep{std::chrono::milliseconds(0), + std::chrono::milliseconds(0)}; + +QCoro::Task capture(GameProcessMonitor& monitor) { + co_await monitor.captureBaseline(); + co_return true; +} + +void touch(const QString& path) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); +} + +void makeExecutable(const QString& path) { + touch(path); + REQUIRE(QFile::setPermissions(path, QFileDevice::ReadOwner | + QFileDevice::WriteOwner | + QFileDevice::ExeOwner)); +} + +std::unique_ptr contextFor(const QString& gamePath) { + static QTemporaryDir configDir; + static px::modrim::ConfigService configService(configDir.path()); + AppConfig cfg; + cfg.paths.game = gamePath.toStdString(); + return CoreContext::create(cfg, &configService); +} + +} // namespace + +TEST_CASE( + "command without wrapper is the executable followed by the arguments") { + auto command = buildDirectCommand(QStringLiteral("/g/RimWorldLinux"), {}, + QStringLiteral("-popupwindow -logfile"), + Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == + QStringList{"/g/RimWorldLinux", "-popupwindow", "-logfile"}); + CHECK(command->env.isEmpty()); +} + +TEST_CASE("wrapper without placeholder is a prefix") { + auto command = + buildDirectCommand(QStringLiteral("/g/rw"), QStringLiteral("gamemoderun"), + QStringLiteral("-x"), Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"gamemoderun", "/g/rw", "-x"}); +} + +TEST_CASE( + "placeholder marks the game position and leading assignments become " + "environment") { + auto command = buildDirectCommand( + QStringLiteral("/g/rw"), + QStringLiteral("MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail"), + QStringLiteral("-x"), Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"mangohud", "/g/rw", "-x", "--tail"}); + CHECK(command->env.value(QStringLiteral("MANGOHUD")) == QStringLiteral("1")); + CHECK(command->env.value(QStringLiteral("DXVK_HUD")) == + QStringLiteral("fps")); +} + +TEST_CASE("quoted POSIX arguments stay one token") { + auto command = buildDirectCommand( + QStringLiteral("/g/rw"), {}, + QStringLiteral(R"(-savedatafolder="/tmp/my saves")"), Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"/g/rw", "-savedatafolder=/tmp/my saves"}); +} + +TEST_CASE("an option containing '=' is not an environment assignment") { + auto command = + buildDirectCommand(QStringLiteral("/g/rw"), + QStringLiteral("--cfg=/p wrap"), {}, Platform::kLinux); + REQUIRE(command.has_value()); + CHECK(command->argv == QStringList{"--cfg=/p", "wrap", "/g/rw"}); + CHECK(command->env.isEmpty()); +} + +TEST_CASE("unbalanced quotes are rejected") { + CHECK_FALSE(buildDirectCommand(QStringLiteral("/g/rw"), {}, + QStringLiteral("\"oops"), Platform::kLinux) + .has_value()); +} + +TEST_CASE( + "Windows splitting keeps quotes in the token, as shlex with posix=False " + "does") { + auto tokens = + splitArguments(QStringLiteral(R"(-a "b c" d)"), Platform::kWindows); + REQUIRE(tokens.has_value()); + CHECK(*tokens == QStringList{"-a", R"("b c")", "d"}); +} + +TEST_CASE("executable lookup prefers the current binary over the legacy one") { +#ifdef Q_OS_WIN + SKIP("Windows decides executability by extension, not permission bits"); +#endif + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorld"))); + auto found = findExecutable(root, Platform::kLinux); + REQUIRE(found.has_value()); + CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorld"))); +} + +TEST_CASE("executable lookup skips a non-executable binary") { +#ifdef Q_OS_WIN + SKIP("Windows decides executability by extension, not permission bits"); +#endif + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + touch(QDir(root).filePath(QStringLiteral("RimWorld"))); + makeExecutable(QDir(root).filePath(QStringLiteral("RimWorldLinux"))); + REQUIRE( + QFile::setPermissions(QDir(root).filePath(QStringLiteral("RimWorld")), + QFileDevice::ReadOwner | QFileDevice::WriteOwner)); + + auto found = findExecutable(root, Platform::kLinux); + REQUIRE(found.has_value()); + CHECK(*found == QDir(root).filePath(QStringLiteral("RimWorldLinux"))); +} + +TEST_CASE("executable lookup finds the Windows and macOS layouts") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString root = dir.path(); + + touch(QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); + auto windows = findExecutable(root, Platform::kWindows); + REQUIRE(windows.has_value()); + CHECK(*windows == QDir(root).filePath(QStringLiteral("RimWorldWin64.exe"))); + + REQUIRE(QDir(root).mkpath(QStringLiteral("RimWorldMac.app"))); + auto mac = findExecutable(root, Platform::kMacOs); + REQUIRE(mac.has_value()); + CHECK(*mac == QDir(root).filePath(QStringLiteral("RimWorldMac.app"))); +} + +TEST_CASE("steam URL is the app id alone without launch arguments") { + CHECK(steamRunUrl(QString()) == QStringLiteral("steam://rungameid/294100")); + CHECK(steamRunUrl(QStringLiteral(" ")) == + QStringLiteral("steam://rungameid/294100")); +} + +TEST_CASE("steam URL percent-encodes launch arguments") { + CHECK(steamRunUrl(QStringLiteral("-savedatafolder=/a b")) == + QStringLiteral("steam://run/294100//-savedatafolder%3D%2Fa%20b/")); +} + +TEST_CASE("legacy and current game process names are recognized") { + for (const char* name : + {"RimWorld", "RimWorld.exe", "RimWorldLinux", "RimWorldWin64.exe", + "RimWorldWin.exe", "RimWorld by Ludeon Studios"}) { + const auto lister = + replay({{proc(QString::fromLatin1(name)), proc("bash", 2)}}); + CHECK(isGameRunning(lister)); + } +} + +TEST_CASE("zombie game process is not running") { + const auto lister = + replay({{proc("RimWorldLinux", 1, true), proc("bash", 2)}}); + CHECK_FALSE(isGameRunning(lister)); +} + +TEST_CASE("wait_for_exit returns once the game processes disappear") { + EventLoop loop; + // The first snapshot is the baseline, which is empty; then the game runs, + // then exits. + GameProcessMonitor monitor( + replay({{}, {proc("RimWorldLinux")}, {proc("RimWorldLinux")}, {}}), + kNoSleep); + REQUIRE(runAsync(capture(monitor))); + CHECK(runAsync(monitor.waitForExit()) == std::nullopt); +} + +TEST_CASE("processes running before the launch are not counted as started") { + EventLoop loop; + // Baseline holds PID 10; the first poll still sees only PID 10, the second + // sees PID 20 too. + GameProcessMonitor monitor( + replay({{proc("RimWorldLinux", 10)}, + {proc("RimWorldLinux", 10)}, + {proc("RimWorldLinux", 10), proc("RimWorldLinux", 20)}}), + kNoSleep); + REQUIRE(runAsync(capture(monitor))); + CHECK(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); +} + +TEST_CASE( + "wait_for_start is false when the spawned process died before the game " + "appeared") { + EventLoop loop; + GameProcessMonitor monitor(replay({{}}), kNoSleep); + monitor.attachProcess( + std::make_unique()); // never started: state NotRunning + CHECK_FALSE(runAsync(monitor.waitForStart(std::chrono::milliseconds(5000)))); +} + +#if defined(Q_OS_UNIX) +TEST_CASE("wait_for_exit reports the spawned process's exit code") { + EventLoop loop; + auto spawned = std::make_unique(); + spawned->start(QStringLiteral("/bin/sh"), + {QStringLiteral("-c"), QStringLiteral("exit 7")}); + REQUIRE(spawned->waitForFinished(10'000)); + + GameProcessMonitor monitor(replay({{}}), kNoSleep); + monitor.attachProcess(std::move(spawned)); + CHECK(runAsync(monitor.waitForExit()) == std::optional(7)); +} +#endif + +TEST_CASE("launch without a game path fails before anything runs") { + EventLoop loop; + auto ctx = contextFor({}); + GameLauncher launcher(*ctx); + const Result result = + runAsync(launcher.launch(LaunchStrategy::kDirect)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Game path not configured")); +} + +TEST_CASE("direct launch without an executable fails") { + EventLoop loop; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + auto ctx = contextFor(dir.path()); + GameLauncher launcher(*ctx); + const Result result = + runAsync(launcher.launch(LaunchStrategy::kDirect)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == QStringLiteral("Game executable not found")); +} diff --git a/tests/core/services/test_metadata_cache.cpp b/tests/core/services/test_metadata_cache.cpp new file mode 100644 index 0000000..b0b1a36 --- /dev/null +++ b/tests/core/services/test_metadata_cache.cpp @@ -0,0 +1,376 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/models/metadata/parsing.hpp" +#include "core/providers/local.hpp" +#include "core/services/metadata_cache.hpp" +#include "services_support.hpp" + +using namespace px::modrim; +using namespace px::modrim::providers; +using namespace px::modrim::services; +using services_test::EventLoop; +using services_test::runAsync; +using services_test::writeText; + +namespace { + +constexpr auto kAboutV1 = R"( + + Sample Mod One + TestAuthor + Author.SampleModOne + +
  • 1.4
  • +
  • 1.5
  • +
    + Initial description + 1.0.0 + https://example.com/mod + UI/Icon.png + +
  • + ludeon.rimworld + RimWorld +
  • +
    +
    +)"; + +// Same length as kAboutV1, so only the mtime tells the two apart. +constexpr auto kAboutSameSize = R"( + + Sample Mod Two + TestAuthor + Author.SampleModOne + +
  • 1.4
  • +
  • 1.5
  • +
    + Initial description + 1.0.0 + https://example.com/mod + UI/Icon.png + +
  • + ludeon.rimworld + RimWorld +
  • +
    +
    +)"; + +constexpr auto kAboutTwo = R"( + + Sample Mod Two + OtherAuthor + Author.SampleModTwo + +
  • 1.5
  • +
    + Second mod description +
    +)"; + +// Writes About/About.xml (and optionally About/PublishedFileId.txt) under +// `modDir`. +QString createMod(const QString& modDir, const char* xml, + bool withPublishedFileId = false) { + REQUIRE(QDir().mkpath(QDir(modDir).filePath(QStringLiteral("About")))); + REQUIRE( + writeText(QDir(modDir).filePath(QStringLiteral("About/About.xml")), xml)); + if (withPublishedFileId) { + REQUIRE(writeText( + QDir(modDir).filePath(QStringLiteral("About/PublishedFileId.txt")), + "123456")); + } + return modDir; +} + +// Stat data the way the providers take it: About.xml mtime in seconds, and its +// size. +ModCandidate candidateFor(const QString& modDir) { + const QString about = + QDir(modDir).filePath(QStringLiteral("About/About.xml")); + const QFileInfo info(about); + return ModCandidate{modDir, about, + info.lastModified().toMSecsSinceEpoch() / 1000.0, + info.size()}; +} + +// Parses the mod exactly as the providers do, so cache entries match real ones. +std::shared_ptr parseAbout(const QString& modDir) { + auto listed = createListedModFromPath(modDir, QStringLiteral("1.5"), true); + REQUIRE(listed.has_value()); + auto about = std::dynamic_pointer_cast(*listed); + REQUIRE(about != nullptr); + return about; +} + +// Moves About.xml's mtime, keeping the file's contents. +void setAboutMtime(const ModCandidate& candidate, double mtimeSeconds) { + QFile file(candidate.aboutXmlPath); + REQUIRE(file.open(QIODevice::ReadWrite)); + REQUIRE(file.setFileTime( + QDateTime::fromMSecsSinceEpoch(std::llround(mtimeSeconds * 1000)), + QFileDevice::FileModificationTime)); +} + +// Reads mod_path values with an independent connection, to check what the cache +// stored. +QStringList storedModPaths(const QString& dbPath) { + const QString name = QStringLiteral("metadata-cache-test-reader"); + QStringList paths; + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), name); + db.setDatabaseName(dbPath); + REQUIRE(db.open()); + QSqlQuery query(db); + REQUIRE(query.exec( + QStringLiteral("SELECT mod_path FROM mod_metadata ORDER BY mod_path"))); + while (query.next()) { + paths.append(query.value(0).toString()); + } + } + QSqlDatabase::removeDatabase(name); + return paths; +} + +} // namespace + +TEST_CASE("cache returns the parsed fields of a stored mod") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); + MetadataCache cache(dbPath); + + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + const CacheLookup lookup = + runAsync(cache.getMods({candidate}, QStringLiteral("1.5"))); + + REQUIRE(lookup.cached.size() == 1); + REQUIRE(lookup.missing.isEmpty()); + const auto mod = std::dynamic_pointer_cast(lookup.cached[0]); + REQUIRE(mod != nullptr); + CHECK(mod->name == QStringLiteral("Sample Mod One")); + CHECK(mod->packageId.str() == QStringLiteral("author.samplemodone")); + CHECK(mod->authors == QStringList{QStringLiteral("TestAuthor")}); + CHECK(mod->supportedVersions == + QSet{QStringLiteral("1.4"), QStringLiteral("1.5")}); + CHECK(mod->description == QStringLiteral("Initial description")); + CHECK(mod->modVersion == QStringLiteral("1.0.0")); + CHECK(mod->url == QStringLiteral("https://example.com/mod")); + REQUIRE(mod->modIconPath.has_value()); + CHECK(*mod->modIconPath == QStringLiteral("UI/Icon.png")); + CHECK(std::abs(mod->mtime - candidate.mtime) < 1e-3); + CHECK(mod->aboutRules.dependencies.size() == 1); + CHECK(mod->aboutRules.hasDependency( + CaseInsensitiveStr(QStringLiteral("ludeon.rimworld")))); + REQUIRE(mod->modPath().has_value()); + CHECK(*mod->modPath() == modDir); +} + +TEST_CASE( + "cache entries are misses when the file size, mtime or target version " + "changes") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + MetadataCache cache(metadataCachePath(root.path())); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + + ModCandidate resized = candidate; + resized.size += 1; + ModCandidate touched = candidate; + touched.mtime += 5.0; + + for (const ModCandidate& stale : {resized, touched}) { + const CacheLookup lookup = + runAsync(cache.getMods({stale}, QStringLiteral("1.5"))); + CHECK(lookup.cached.isEmpty()); + CHECK(lookup.missing.size() == 1); + } + const CacheLookup otherVersion = + runAsync(cache.getMods({candidate}, QStringLiteral("1.4"))); + CHECK(otherVersion.cached.isEmpty()); + CHECK(otherVersion.missing.size() == 1); +} + +TEST_CASE("pruning removes entries for mods that disappeared") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modA = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const QString modB = + createMod(QDir(root.path()).filePath(QStringLiteral("ModB")), kAboutTwo); + const QString dbPath = metadataCachePath(root.path()); + MetadataCache cache(dbPath); + runAsync(cache.putMods({CachedMod{parseAbout(modA), candidateFor(modA)}, + CachedMod{parseAbout(modB), candidateFor(modB)}}, + QStringLiteral("1.5"))); + QStringList expectedPaths{normalizePath(modA), normalizePath(modB)}; + expectedPaths.sort(); + REQUIRE(storedModPaths(dbPath) == expectedPaths); + + runAsync(cache.pruneDisappeared(QSet{normalizePath(modA)})); + + CHECK(storedModPaths(dbPath) == QStringList{normalizePath(modA)}); + const CacheLookup lookup = + runAsync(cache.getMods({candidateFor(modB)}, QStringLiteral("1.5"))); + CHECK(lookup.cached.isEmpty()); +} + +TEST_CASE("corrupt cache database is recovered and keeps working") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modDir = + createMod(QDir(root.path()).filePath(QStringLiteral("ModA")), kAboutV1); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); + { + MetadataCache cache(dbPath); + runAsync(cache.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + runAsync(cache.close()); + } + QByteArray garbage; + for (int i = 0; i < 50; ++i) { + garbage += "corrupt non-sqlite data"; + } + REQUIRE(writeText(dbPath, garbage)); + + MetadataCache recovered(dbPath); + const CacheLookup empty = + runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); + CHECK(empty.cached.isEmpty()); + REQUIRE(empty.missing.size() == 1); + + runAsync(recovered.putMods({CachedMod{parseAbout(modDir), candidate}}, + QStringLiteral("1.5"))); + const CacheLookup warm = + runAsync(recovered.getMods({candidate}, QStringLiteral("1.5"))); + CHECK(warm.cached.size() == 1); +} + +TEST_CASE("providers reuse cached metadata and reparse when the file changes") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString modsRoot = QDir(root.path()).filePath(QStringLiteral("mods")); + const QString modDir = + createMod(QDir(modsRoot).filePath(QStringLiteral("ModA")), kAboutV1); + const QString about = + QDir(modDir).filePath(QStringLiteral("About/About.xml")); + const ModCandidate original = candidateFor(modDir); + MetadataCache cache(metadataCachePath(root.path())); + LocalModProvider provider(modsRoot, nullptr, &cache); + + auto first = runAsync(provider.discover(QStringLiteral("1.5"))); + REQUIRE(first.has_value()); + REQUIRE(first->contains(modDir)); + CHECK((*first)[modDir]->name == QStringLiteral("Sample Mod One")); + + // Same size and mtime, different text: the cache still answers, a forced + // reparse does not. + REQUIRE(writeText(about, kAboutSameSize)); + setAboutMtime(original, original.mtime); + auto cached = runAsync(provider.discover(QStringLiteral("1.5"))); + REQUIRE(cached.has_value()); + CHECK((*cached)[modDir]->name == QStringLiteral("Sample Mod One")); + auto forced = runAsync( + provider.discover(QStringLiteral("1.5"), nullptr, nullptr, true)); + REQUIRE(forced.has_value()); + CHECK((*forced)[modDir]->name == QStringLiteral("Sample Mod Two")); + + // A new mtime invalidates the entry even though the cache is consulted. + setAboutMtime(original, original.mtime + 5.0); + auto changed = runAsync(provider.discover(QStringLiteral("1.5"))); + REQUIRE(changed.has_value()); + CHECK((*changed)[modDir]->name == QStringLiteral("Sample Mod Two")); + // mtime lives on AboutXmlMod; ListedMod has no such field. + const auto changedAbout = + std::dynamic_pointer_cast((*changed)[modDir]); + REQUIRE(changedAbout != nullptr); + CHECK(std::abs(changedAbout->mtime - (original.mtime + 5.0)) < 1e-3); +} + +TEST_CASE("providers keep their identity when they share one cache") { + EventLoop loop; + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString localRoot = QDir(root.path()).filePath(QStringLiteral("local")); + const QString downloadedRoot = + QDir(root.path()).filePath(QStringLiteral("downloaded")); + const QString localMod = + createMod(QDir(localRoot).filePath(QStringLiteral("LocalMod")), kAboutV1); + const QString steamMod = + createMod(QDir(downloadedRoot).filePath(QStringLiteral("SteamMod")), + kAboutTwo, true); + MetadataCache cache(metadataCachePath(root.path())); + LocalModProvider local(localRoot, nullptr, &cache); + DownloadedModProvider downloaded(downloadedRoot, nullptr, &cache); + + for (int pass = 0; pass < 2; ++pass) { + auto localMods = runAsync(local.discover(QStringLiteral("1.5"))); + auto steamMods = runAsync(downloaded.discover(QStringLiteral("1.5"))); + REQUIRE(localMods.has_value()); + REQUIRE(steamMods.has_value()); + REQUIRE(localMods->contains(localMod)); + REQUIRE(steamMods->contains(steamMod)); + CHECK((*localMods)[localMod]->providerId == QStringLiteral("local")); + CHECK((*steamMods)[steamMod]->providerId == QStringLiteral("downloaded")); + } +} + +TEST_CASE("normalize_paths matches normalize_path, including symlinks") { +#ifdef Q_OS_WIN + SKIP("QFile::link makes .lnk shortcuts on Windows, not symlinks"); +#endif + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString real = QDir(root.path()).filePath(QStringLiteral("real")); + const QString other = QDir(root.path()).filePath(QStringLiteral("other")); + REQUIRE(QDir().mkpath(QDir(real).filePath(QStringLiteral("mod")))); + REQUIRE(QDir().mkpath(other)); + REQUIRE( + QFile::link(real, QDir(root.path()).filePath(QStringLiteral("linkdir")))); + REQUIRE(QFile::link(other, QDir(real).filePath(QStringLiteral("linkmod")))); + + const QString linkdir = QDir(root.path()).filePath(QStringLiteral("linkdir")); + const QStringList paths{ + QDir(real).filePath(QStringLiteral("mod")), + QDir(linkdir).filePath(QStringLiteral("mod")), + QDir(linkdir).filePath(QStringLiteral("linkmod")), + QDir(root.path()).filePath(QStringLiteral("linkdir/../real/mod")), + QDir(root.path()).filePath(QStringLiteral("missing/x")), + QStringLiteral("relative/mod"), + }; + + QStringList expected; + for (const QString& path : paths) { + expected.append(normalizePath(path)); + } + CHECK(normalizePaths(paths) == expected); + CHECK(normalizePath(QDir(linkdir).filePath(QStringLiteral("linkmod"))) == + normalizePath(other)); +} diff --git a/tests/core/services/test_sort_service.cpp b/tests/core/services/test_sort_service.cpp new file mode 100644 index 0000000..04d2d59 --- /dev/null +++ b/tests/core/services/test_sort_service.cpp @@ -0,0 +1,210 @@ +#include +#include +#include +#include +#include + +#include + +#include "core/services/sort_service.hpp" + +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveStr; +using px::modrim::DependencyMod; +using px::modrim::ListedMod; +using px::modrim::ListedModPtr; +using px::modrim::SortService; +using px::modrim::sort::PackageId; + +namespace { + +std::shared_ptr about(const QString& name, + const QString& packageId) { + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("stub"); + mod->valid = true; + return mod; +} + +// `deps` maps a dependency package id to its alternative package ids. +std::shared_ptr aboutWithDeps( + const QString& name, const QString& packageId, + const QHash& deps) { + auto mod = about(name, packageId); + for (auto it = deps.cbegin(); it != deps.cend(); ++it) { + DependencyMod dep; + dep.name = QStringLiteral("Dep of %1").arg(name); + dep.packageId = CaseInsensitiveStr(it.key()); + for (const auto& alt : it.value()) { + dep.alternativePackageIds.add(alt); + } + mod->aboutRules.dependencies.append(std::move(dep)); + } + return mod; +} + +ListedModPtr listed(const QString& name) { + auto mod = std::make_shared(); + mod->name = name; + mod->providerId = QStringLiteral("stub"); + return mod; +} + +QHash mods( + std::initializer_list> entries) { + QHash result; + for (const auto& [uuid, mod] : entries) { + result.insert(uuid, mod); + } + return result; +} + +} // namespace + +TEST_CASE("duplicate inactive copy does not replace the active uuid") { + const auto all = mods({ + {QStringLiteral("uuid-active"), about("Active Copy", "shared.package")}, + {QStringLiteral("uuid-inactive"), + about("Inactive Copy", "shared.package")}, + }); + + const auto ordered = + SortService::orderUuids({PackageId(QStringLiteral("shared.package"))}, + all, {QStringLiteral("uuid-active")}); + + CHECK(ordered == QStringList{QStringLiteral("uuid-active")}); +} + +TEST_CASE("order_uuids follows the sorted package ids and drops unknown ones") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + const auto ordered = SortService::orderUuids( + {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("ghost")), + PackageId(QStringLiteral("mod.a"))}, + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); + + CHECK(ordered == + QStringList{QStringLiteral("uuid-b"), QStringLiteral("uuid-a")}); +} + +TEST_CASE("order_uuids skips active mods that are not about.xml mods") { + const auto all = mods({ + {QStringLiteral("uuid-folder"), listed("Folder Mod")}, + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + }); + + const auto ordered = SortService::orderUuids( + {PackageId(QStringLiteral("mod.a"))}, all, + {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}); + + CHECK(ordered == QStringList{QStringLiteral("uuid-a")}); +} + +TEST_CASE("missing dependencies: no dependencies yields nothing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); +} + +TEST_CASE("missing dependency that exists as an inactive mod is enabled") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}) == + QStringList{QStringLiteral("uuid-b")}); +} + +TEST_CASE("missing dependency that is not among the known mods is ignored") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + }); + + CHECK(SortService::missingDependencyUuids(all, {QStringLiteral("uuid-a")}) + .isEmpty()); +} + +TEST_CASE("missing dependency that is already active is not enabled again") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); +} + +TEST_CASE( + "dependency satisfied by an active alternative package id is not missing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {QStringLiteral("mod.c")}}})}, + {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-c")}) + .isEmpty()); +} + +TEST_CASE("non about.xml mods in the active set contribute no dependencies") { + const auto all = mods({ + {QStringLiteral("uuid-folder"), listed("Folder Mod")}, + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-folder"), QStringLiteral("uuid-a")}) + .isEmpty()); +} + +TEST_CASE("a dependency shared by two active mods is listed once") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.c", {}}})}, + {QStringLiteral("uuid-b"), + aboutWithDeps("Mod B", "mod.b", {{"mod.c", {}}})}, + {QStringLiteral("uuid-c"), about("Mod C", "mod.c")}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) == + QStringList{QStringLiteral("uuid-c")}); +} + +TEST_CASE("an empty active set needs nothing") { + const auto all = mods({ + {QStringLiteral("uuid-a"), + aboutWithDeps("Mod A", "mod.a", {{"mod.b", {}}})}, + {QStringLiteral("uuid-b"), about("Mod B", "mod.b")}, + }); + + CHECK(SortService::missingDependencyUuids(all, {}).isEmpty()); +} + +TEST_CASE("a mod without dependencies adds nothing to the result") { + const auto all = mods({ + {QStringLiteral("uuid-a"), about("Mod A", "mod.a")}, + {QStringLiteral("uuid-b"), + aboutWithDeps("Mod B", "mod.b", {{"mod.a", {}}})}, + }); + + CHECK(SortService::missingDependencyUuids( + all, {QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}) + .isEmpty()); +} diff --git a/tests/core/services/test_update_service.cpp b/tests/core/services/test_update_service.cpp new file mode 100644 index 0000000..75b5390 --- /dev/null +++ b/tests/core/services/test_update_service.cpp @@ -0,0 +1,196 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/services/update_service.hpp" +#include "services_support.hpp" + +using namespace px::modrim; +using namespace px::modrim::services; +using services_test::EventLoop; +using services_test::runAsync; + +namespace { + +// Minimal local HTTP server serving a single canned response per connection. +class CannedHttpServer : public QObject { + public: + CannedHttpServer(int statusCode, QByteArray body, + QByteArray contentType = "application/json") + : m_statusCode(statusCode), + m_body(std::move(body)), + m_contentType(std::move(contentType)) { + connect(&m_server, &QTcpServer::newConnection, this, + &CannedHttpServer::onNewConnection); + REQUIRE(m_server.listen(QHostAddress::LocalHost, 0)); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1/latest") + .arg(m_server.serverPort()); + } + + private: + void onNewConnection() { + while (QTcpSocket* socket = m_server.nextPendingConnection()) { + connect(socket, &QTcpSocket::readyRead, this, [this, socket] { + if (socket->canReadLine()) { + // Drain the headers. + while (!socket->readLine().trimmed().isEmpty()) { + } + const QByteArray header = + QByteArray("HTTP/1.1 ") + QByteArray::number(m_statusCode) + + " OK\r\n" + "Content-Type: " + m_contentType + "\r\n" + + "Content-Length: " + QByteArray::number(m_body.size()) + "\r\n" + + "Connection: close\r\n\r\n"; + socket->write(header + m_body); + socket->disconnectFromHost(); + } + }); + } + } + + QTcpServer m_server; + int m_statusCode; + QByteArray m_body; + QByteArray m_contentType; +}; + +// Returns a port on localhost that is not currently listening. +quint16 closedPort() { + QTcpServer server; + REQUIRE(server.listen(QHostAddress::LocalHost, 0)); + const quint16 port = server.serverPort(); + server.close(); + return port; +} + +constexpr auto kReleaseJson = R"({ + "tag_name": "v0.2.0", + "html_url": "https://example.test/r/v0.2.0", + "name": "Second", + "body": "notes" +})"; + +Result> checkPayload( + const QByteArray& payload, int status = 200, + const QString& current = QStringLiteral("0.1.0")) { + EventLoop loop; + QNetworkAccessManager network; + CannedHttpServer server(status, payload); + UpdateService service(current, network, server.url()); + return runAsync(service.check()); +} + +} // namespace + +TEST_CASE( + "is_newer compares versions with padding, pre-release suffixes and dev " + "versions") { + struct Case { + const char* tag; + const char* current; + bool expected; + }; + const std::vector cases = { + {"v0.2.0", "0.1.0", true}, {"v0.1.0", "0.1.0", false}, + {"v0.1.0", "0.2.0", false}, {"v0.10.0", "0.9.0", true}, + {"v1.0", "1.0.0", false}, {"v1.0.1", "1.0", true}, + {"v0.2.0-beta.1", "0.1.0", true}, {"nightly", "0.1.0", false}, + {"v0.2.0", "unknown", false}, {"v0.2.0", "0.2.0-rc1", true}, + {"v0.2.0-rc1", "0.2.0", false}, {"v0.2.0-rc2", "0.2.0-rc1", true}, + {"v0.2.0-rc1", "0.2.0-rc1", false}, {"v0.2.0-rc1", "0.1.9", true}, + {"v0.1.9", "0.2.0-rc1", false}, {"v0.2.0+build5", "0.2.0", false}, + }; + for (const auto& [tag, current, expected] : cases) { + CAPTURE(tag, current); + CHECK(isNewer(QString::fromLatin1(tag), QString::fromLatin1(current)) == + expected); + } +} + +TEST_CASE( + "parse_version extracts leading numeric parts and rejects non-numeric " + "tags") { + CHECK(parseVersion(QStringLiteral("latest")) == std::nullopt); + const auto parsed = parseVersion(QStringLiteral(" v1.2.3 ")); + REQUIRE(parsed.has_value()); + CHECK(*parsed == std::vector{1, 2, 3}); +} + +TEST_CASE("UpdateService returns a newer release") { + const auto result = checkPayload(kReleaseJson); + REQUIRE(result.has_value()); + REQUIRE(result->has_value()); + const ReleaseInfo& release = **result; + CHECK(release.tag == QStringLiteral("v0.2.0")); + CHECK(release.name == QStringLiteral("Second")); + CHECK(release.notes == QStringLiteral("notes")); + CHECK(release.url == QStringLiteral("https://example.test/r/v0.2.0")); +} + +TEST_CASE( + "UpdateService returns nullopt when the latest release is not newer") { + const auto result = checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0")); + REQUIRE(result.has_value()); + CHECK(!result->has_value()); +} + +TEST_CASE("UpdateService offers a plain release over its own rc") { + const auto result = + checkPayload(kReleaseJson, 200, QStringLiteral("0.2.0-rc1")); + REQUIRE(result.has_value()); + REQUIRE(result->has_value()); + CHECK((**result).tag == QStringLiteral("v0.2.0")); +} + +TEST_CASE("UpdateService wraps HTTP status errors") { + const auto result = checkPayload("{}", 500); + CHECK(!result.has_value()); +} + +TEST_CASE("UpdateService wraps transport failures") { + EventLoop loop; + QNetworkAccessManager network; + const QString deadUrl = + QStringLiteral("http://127.0.0.1:%1/latest").arg(closedPort()); + UpdateService service(QStringLiteral("0.1.0"), network, deadUrl); + const auto result = runAsync(service.check()); + CHECK(!result.has_value()); +} + +TEST_CASE("UpdateService wraps non-JSON bodies") { + const auto result = checkPayload("", 200); + CHECK(!result.has_value()); +} + +TEST_CASE("UpdateService rejects non-object JSON payloads") { + const std::initializer_list payloads{"[]", "null", "\"tag\"", + "3"}; + for (const QByteArray& payload : payloads) { + CAPTURE(payload); + const auto result = checkPayload(payload); + CHECK(!result.has_value()); + } +} + +TEST_CASE("UpdateService rejects responses lacking tag_name or html_url") { + for (const QByteArray& payload : { + QByteArray("{}"), + QByteArray(R"({"tag_name": "v0.2.0"})"), + QByteArray(R"({"html_url": "https://example.test/r"})"), + QByteArray( + R"({"tag_name": "", "html_url": "https://example.test/r"})"), + }) { + CAPTURE(payload); + const auto result = checkPayload(payload); + CHECK(!result.has_value()); + } +} diff --git a/tests/core/sort/test_community.cpp b/tests/core/sort/test_community.cpp new file mode 100644 index 0000000..5e9e6d6 --- /dev/null +++ b/tests/core/sort/test_community.cpp @@ -0,0 +1,61 @@ +#include +#include + +#include + +#include "../test_support.hpp" +#include "core/sort/community.hpp" + +using px::modrim::sort::loadCommunityRules; +using px::modrim::sort::PackageId; + +TEST_CASE("a missing community rules file means no rules") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const auto rules = + loadCommunityRules(dir.filePath(QStringLiteral("communityRules.json"))); + + REQUIRE(rules.has_value()); + CHECK(rules->isEmpty()); +} + +TEST_CASE("community rules are keyed by lowercased package id") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("communityRules.json")); + REQUIRE(test_support::writeBytes(path, R"({ + "timestamp": 1, + "rules": { + "Some.Mod": { + "loadAfter": {"Base.Mod": {"name": "Base"}}, + "loadBefore": {"Late.Mod": {}}, + "loadTop": {"value": true}, + "loadBottom": {"value": false} + } + } + })")); + + const auto loaded = loadCommunityRules(path); + + REQUIRE(loaded.has_value()); + REQUIRE(loaded->contains(PackageId(QStringLiteral("some.mod")))); + const auto& rule = loaded->value(PackageId(QStringLiteral("some.mod"))); + CHECK(rule.packageId == PackageId(QStringLiteral("some.mod"))); + CHECK(rule.loadAfter.contains(QStringLiteral("base.mod"))); + CHECK(rule.loadBefore.contains(QStringLiteral("late.mod"))); + CHECK(rule.loadFirst); + CHECK_FALSE(rule.loadLast); + CHECK(rule.incompatibleWith.empty()); +} + +TEST_CASE("a malformed community rules file is an error") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("communityRules.json")); + REQUIRE(test_support::writeBytes(path, R"({"rules": [1, 2)")); + + const auto loaded = loadCommunityRules(path); + + CHECK_FALSE(loaded.has_value()); +} diff --git a/tests/core/sort/test_tier_order.cpp b/tests/core/sort/test_tier_order.cpp new file mode 100644 index 0000000..af9d00d --- /dev/null +++ b/tests/core/sort/test_tier_order.cpp @@ -0,0 +1,197 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "core/checker/graph.hpp" +#include "core/checker/sort.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/sort/config.hpp" + +using px::modrim::AboutXmlMod; +using px::modrim::CaseInsensitiveSet; +using px::modrim::CaseInsensitiveStr; +using px::modrim::checker::ConstraintGraph; +using px::modrim::checker::topologicalSort; +using px::modrim::sort::PackageId; +using px::modrim::sort::SortSettings; +using px::modrim::sort::Tier; +using px::modrim::sort::TierConfig; + +namespace { + +using ModPtr = std::shared_ptr; +using ModMap = QHash; + +PackageId pid(const QString& value) { return PackageId(value); } + +ModPtr makeMod(const QString& packageId) { + auto mod = std::make_shared(); + mod->packageId = CaseInsensitiveStr(packageId); + mod->name = packageId; + return mod; +} + +ModMap activeDict(std::initializer_list mods) { + ModMap result; + for (const auto& mod : mods) { + result.insert(mod->packageId, mod); + } + return result; +} + +// Community rules are off and alternative ids are ignored, so only the tier +// config and explicit load-order rules shape the result. +QList sortMods( + const ModMap& mods, const TierConfig& tierConfig = TierConfig::defaults()) { + SortSettings settings; + settings.use_community_rules = false; + settings.use_alternative_package_ids = false; + settings.tier_config = tierConfig; + + ConstraintGraph graph; + graph.build(mods, mods.keys(), settings); + return topologicalSort(mods, graph, settings); +} + +// Checks only the entries of `result` that belong to `expected`, in their +// order. +void assertOrder(const QList& result, const QStringList& expected) { + QStringList inTier; + for (const auto& entry : result) { + if (expected.contains(entry.str())) { + inTier.append(entry.str()); + } + } + CHECK(inTier == expected); +} + +} // namespace + +TEST_CASE("tier 0 mods follow the config order") { + const auto mods = activeDict({ + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("ludeon.rimworld")), + makeMod(QStringLiteral("ludeon.rimworld.royalty")), + makeMod(QStringLiteral("ludeon.rimworld.ideology")), + makeMod(QStringLiteral("ludeon.rimworld.biotech")), + makeMod(QStringLiteral("ludeon.rimworld.anomaly")), + makeMod(QStringLiteral("ludeon.rimworld.odyssey")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("brrainz.harmony"), + QStringLiteral("ludeon.rimworld"), + QStringLiteral("ludeon.rimworld.royalty"), + QStringLiteral("ludeon.rimworld.ideology"), + QStringLiteral("ludeon.rimworld.biotech"), + QStringLiteral("ludeon.rimworld.anomaly"), + QStringLiteral("ludeon.rimworld.odyssey")}); +} + +TEST_CASE("tier 0 partial active set keeps config order") { + const auto mods = activeDict({ + makeMod(QStringLiteral("ludeon.rimworld.biotech")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("ludeon.rimworld.royalty")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("ludeon.rimworld.royalty"), + QStringLiteral("ludeon.rimworld.biotech")}); +} + +TEST_CASE("tier 0 load-after rule orders mods within the tier") { + const auto mods = activeDict({ + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("zetrith.prepatcher")), + }); + mods.value(pid(QStringLiteral("brrainz.harmony")))->aboutRules.loadAfter = + CaseInsensitiveSet(QStringList{QStringLiteral("zetrith.prepatcher")}); + + assertOrder(sortMods(mods), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("brrainz.harmony")}); +} + +TEST_CASE("tier 1 mods follow the config order") { + const auto mods = activeDict({ + makeMod(QStringLiteral("unlimitedhugs.hugslib")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("aoba.framework")), + makeMod(QStringLiteral("smashphil.vehicleframework")), + makeMod(QStringLiteral("vanillaexpanded.backgrounds")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("adaptive.storage.framework"), + QStringLiteral("aoba.framework"), + QStringLiteral("smashphil.vehicleframework"), + QStringLiteral("unlimitedhugs.hugslib"), + QStringLiteral("vanillaexpanded.backgrounds")}); +} + +TEST_CASE("tier 1 partial active set keeps config order") { + const auto mods = activeDict({ + makeMod(QStringLiteral("smashphil.vehicleframework")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("vanillaexpanded.backgrounds")), + }); + + assertOrder(sortMods(mods), {QStringLiteral("adaptive.storage.framework"), + QStringLiteral("smashphil.vehicleframework"), + QStringLiteral("vanillaexpanded.backgrounds")}); +} + +TEST_CASE("tier 1 load-after rule orders mods within the tier") { + const auto mods = activeDict({ + makeMod(QStringLiteral("aoba.exosuit.framework")), + makeMod(QStringLiteral("aoba.framework")), + }); + mods.value(pid(QStringLiteral("aoba.exosuit.framework"))) + ->aboutRules.loadAfter = + CaseInsensitiveSet(QStringList{QStringLiteral("aoba.framework")}); + + assertOrder(sortMods(mods), {QStringLiteral("aoba.framework"), + QStringLiteral("aoba.exosuit.framework")}); +} + +TEST_CASE("tiers are ordered: 0 before 1 before 2 before 3") { + const auto mods = activeDict({ + makeMod(QStringLiteral("some.two.mod")), + makeMod(QStringLiteral("brrainz.harmony")), + makeMod(QStringLiteral("adaptive.storage.framework")), + makeMod(QStringLiteral("some.three.mod")), + }); + TierConfig tierConfig; + tierConfig.tier_0 = {pid(QStringLiteral("brrainz.harmony"))}; + tierConfig.tier_1 = {pid(QStringLiteral("adaptive.storage.framework"))}; + tierConfig.tier_3 = {pid(QStringLiteral("some.three.mod"))}; + + const auto result = sortMods(mods, tierConfig); + + CHECK(result.indexOf(pid(QStringLiteral("brrainz.harmony"))) < + result.indexOf(pid(QStringLiteral("adaptive.storage.framework")))); + CHECK(result.indexOf(pid(QStringLiteral("adaptive.storage.framework"))) < + result.indexOf(pid(QStringLiteral("some.two.mod")))); + CHECK(result.indexOf(pid(QStringLiteral("some.two.mod"))) < + result.indexOf(pid(QStringLiteral("some.three.mod")))); +} + +TEST_CASE("mods outside every configured tier come after configured mods") { + const auto mods = activeDict({ + makeMod(QStringLiteral("unknown.first")), + makeMod(QStringLiteral("zetrith.prepatcher")), + makeMod(QStringLiteral("unknown.second")), + makeMod(QStringLiteral("brrainz.harmony")), + }); + TierConfig tierConfig; + tierConfig.tier_0 = {pid(QStringLiteral("zetrith.prepatcher")), + pid(QStringLiteral("brrainz.harmony"))}; + + assertOrder(sortMods(mods, tierConfig), {QStringLiteral("zetrith.prepatcher"), + QStringLiteral("brrainz.harmony")}); +} diff --git a/tests/core/sort/test_tiers.cpp b/tests/core/sort/test_tiers.cpp new file mode 100644 index 0000000..d9aed23 --- /dev/null +++ b/tests/core/sort/test_tiers.cpp @@ -0,0 +1,153 @@ +#include +#include +#include + +#include + +#include "core/sort/tiers.hpp" + +using px::modrim::sort::assignTiers; +using px::modrim::sort::findCycle; +using px::modrim::sort::getDepsRecursive; +using px::modrim::sort::getReverseDepsRecursive; +using px::modrim::sort::PackageGraph; +using px::modrim::sort::PackageId; +using px::modrim::sort::Tier; +using px::modrim::sort::TierConfig; + +namespace { + +PackageId pid(const QString& value) { return PackageId(value); } + +void addEdge(PackageGraph& graph, const QString& from, const QString& to) { + graph[pid(from)].insert(pid(to)); +} + +} // namespace + +TEST_CASE("tier graph algorithms handle long chains") { + constexpr int kCount = 5000; + QList nodes; + for (int index = 0; index < kCount; ++index) { + nodes.append(pid(QStringLiteral("mod.%1").arg(index))); + } + const QSet all(nodes.cbegin(), nodes.cend()); + + PackageGraph deps; + PackageGraph reverseDeps; + for (int index = 0; index + 1 < kCount; ++index) { + deps[nodes[index]].insert(nodes[index + 1]); + reverseDeps[nodes[index + 1]].insert(nodes[index]); + } + + const QSet tail(nodes.cbegin() + 1, nodes.cend()); + const QSet head(nodes.cbegin(), nodes.cend() - 1); + CHECK(getDepsRecursive(nodes.front(), deps) == tail); + CHECK(getReverseDepsRecursive(nodes.back(), reverseDeps) == head); + CHECK_FALSE(findCycle(all, deps).has_value()); + + deps[nodes.back()].insert(nodes.front()); + const auto cycle = findCycle(all, deps); + REQUIRE(cycle.has_value()); + CHECK(cycle->size() == kCount + 1); + CHECK(cycle->front() == cycle->back()); + CHECK(QSet(cycle->cbegin(), cycle->cend() - 1) == all); +} + +TEST_CASE("find_cycle returns a closed path whose steps are dependency edges") { + PackageGraph deps; + addEdge(deps, QStringLiteral("a"), QStringLiteral("b")); + addEdge(deps, QStringLiteral("b"), QStringLiteral("c")); + addEdge(deps, QStringLiteral("c"), QStringLiteral("a")); + addEdge(deps, QStringLiteral("c"), QStringLiteral("d")); + const QSet nodes{ + pid(QStringLiteral("a")), pid(QStringLiteral("b")), + pid(QStringLiteral("c")), pid(QStringLiteral("d"))}; + + const auto cycle = findCycle(nodes, deps); + REQUIRE(cycle.has_value()); + REQUIRE(cycle->size() == 4); + CHECK(cycle->front() == cycle->back()); + for (qsizetype index = 0; index + 1 < cycle->size(); ++index) { + CHECK(deps.value((*cycle)[index]).contains((*cycle)[index + 1])); + } +} + +TEST_CASE("find_cycle ignores edges that leave the node set") { + PackageGraph deps; + addEdge(deps, QStringLiteral("a"), QStringLiteral("outside")); + addEdge(deps, QStringLiteral("outside"), QStringLiteral("a")); + const QSet nodes{pid(QStringLiteral("a"))}; + + CHECK_FALSE(findCycle(nodes, deps).has_value()); +} + +TEST_CASE("assign_tiers places tier 0 mods and their dependencies in tier 0") { + PackageGraph deps; + addEdge(deps, QStringLiteral("zetrith"), QStringLiteral("bar")); + const QSet canonical{pid(QStringLiteral("zetrith")), + pid(QStringLiteral("bar")), + pid(QStringLiteral("other"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("zetrith"))}; + + const auto tiers = assignTiers(canonical, deps, {}, {}, {}, config); + + CHECK(tiers.value(pid(QStringLiteral("zetrith"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("bar"))) == Tier::kTier0); + CHECK(tiers.value(pid(QStringLiteral("other"))) == Tier::kTier2); +} + +TEST_CASE("assign_tiers sinks load-last mods and their dependents to tier 3") { + PackageGraph revDeps; + addEdge(revDeps, QStringLiteral("late"), QStringLiteral("dependent")); + const QSet canonical{pid(QStringLiteral("late")), + pid(QStringLiteral("dependent")), + pid(QStringLiteral("mid"))}; + + const auto tiers = + assignTiers(canonical, {}, revDeps, {}, + QSet{pid(QStringLiteral("late"))}, TierConfig{}); + + CHECK(tiers.value(pid(QStringLiteral("late"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("dependent"))) == Tier::kTier3); + CHECK(tiers.value(pid(QStringLiteral("mid"))) == Tier::kTier2); +} + +TEST_CASE("tier 0 overrides tier 3 on overlap") { + const QSet canonical{pid(QStringLiteral("x"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("x"))}; + + const auto tiers = assignTiers( + canonical, {}, {}, {}, QSet{pid(QStringLiteral("x"))}, config); + + CHECK(tiers.value(pid(QStringLiteral("x"))) == Tier::kTier0); +} + +TEST_CASE("tier 1 only promotes tier 2 mods") { + const QSet canonical{pid(QStringLiteral("first")), + pid(QStringLiteral("last"))}; + const QSet loadFirst{pid(QStringLiteral("first")), + pid(QStringLiteral("last"))}; + const QSet loadLast{pid(QStringLiteral("last"))}; + + const auto tiers = + assignTiers(canonical, {}, {}, loadFirst, loadLast, TierConfig{}); + + CHECK(tiers.value(pid(QStringLiteral("first"))) == Tier::kTier1); + CHECK(tiers.value(pid(QStringLiteral("last"))) == Tier::kTier3); +} + +TEST_CASE("assign_tiers never returns ids outside the canonical set") { + const QSet canonical{pid(QStringLiteral("a"))}; + TierConfig config; + config.tier_0 = {pid(QStringLiteral("ghost"))}; + config.tier_3 = {pid(QStringLiteral("ghost"))}; + + const auto tiers = assignTiers(canonical, {}, {}, {}, {}, config); + + CHECK(tiers.size() == 1); + CHECK_FALSE(tiers.contains(pid(QStringLiteral("ghost")))); + CHECK(tiers.value(pid(QStringLiteral("a"))) == Tier::kTier2); +} diff --git a/tests/core/support/test_report.cpp b/tests/core/support/test_report.cpp new file mode 100644 index 0000000..c769dc6 --- /dev/null +++ b/tests/core/support/test_report.cpp @@ -0,0 +1,389 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "../test_support.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/support/report.hpp" +#include "core/version.hpp" + +using namespace px::modrim; +using namespace px::modrim::support; +using px::modrim::Result; +using test_support::TestApplication; +using test_support::writeBytes; + +namespace { + +// Awaits a coroutine on the event loop. +template +T wait(QCoro::Task task) { + return QCoro::waitFor(std::move(task)); +} + +void requireError(const Result& result, const QString& fragment) { + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.contains(fragment)); +} + +// Answers each request with `response`. An empty response never answers, which +// lets a test hit the client's timeout. The request is recorded for inspection. +class CannedServer { + public: + explicit CannedServer(QByteArray response) : m_response(std::move(response)) { + REQUIRE(m_server.listen(QHostAddress::LocalHost)); + QObject::connect(&m_server, &QTcpServer::newConnection, + [this] { accept(); }); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1/").arg(m_server.serverPort()); + } + + const QByteArray& request() const { return m_request; } + + private: + void accept() { + QTcpSocket* socket = m_server.nextPendingConnection(); + socket->setParent(&m_server); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [this, socket] { + m_request += socket->readAll(); + if (m_response.isEmpty() || !complete(m_request)) { + return; + } + socket->write(m_response); + socket->disconnectFromHost(); + }); + } + + // True once the headers have arrived and the Content-Length body is complete. + static bool complete(const QByteArray& raw) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + if (headersEnd < 0) { + return false; + } + const QByteArray headers = raw.left(headersEnd).toLower(); + const qsizetype key = headers.indexOf("content-length:"); + if (key < 0) { + return true; + } + qsizetype eol = headers.indexOf("\r\n", key); + if (eol < 0) { + eol = headers.size(); + } + const qint64 length = + headers.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + return raw.size() - (headersEnd + 4) >= length; + } + + QTcpServer m_server; + QByteArray m_response; + QByteArray m_request; +}; + +QByteArray httpResponse(const QByteArray& statusLine, const QByteArray& body) { + return "HTTP/1.1 " + statusLine + + "\r\nContent-Type: text/plain\r\nContent-Length: " + + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + + body; +} + +ListedModPtr aboutMod(const QString& name, const QString& packageId) { + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(packageId); + return mod; +} + +} // namespace + +TEST_CASE("redact replaces the home directory and username in user paths") { + CHECK( + redact(QStringLiteral("User alice at /home/alice/.config/log.txt and " + "/mnt/home/alice/x"), + QStringLiteral("/home/alice"), QStringLiteral("alice")) == + QStringLiteral("User alice at ~/.config/log.txt and /mnt/home//x")); +} + +TEST_CASE("redact handles Windows-style paths") { + CHECK(redact(QString::fromLatin1( + R"(Loaded C:\Users\Bob\AppData\Local\pxmodrim\test.log)"), + QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == + QString::fromLatin1(R"(Loaded ~\AppData\Local\pxmodrim\test.log)")); + CHECK(redact(QStringLiteral("Loaded C:/Users/Bob/AppData/test.log"), + QString::fromLatin1(R"(C:\Users\Bob)"), QStringLiteral("Bob")) == + QStringLiteral("Loaded ~/AppData/test.log")); +} + +TEST_CASE("redact respects path boundaries around the home directory") { + CHECK(redact(QStringLiteral("/home/bob/a and /home/bobby/b and /home/bob"), + QStringLiteral("/home/bob"), QStringLiteral("bob")) == + QStringLiteral("~/a and /home/bobby/b and ~")); +} + +TEST_CASE("redact redacts usernames on removable media mounts") { + CHECK( + redact(QStringLiteral("/run/media/alice/SD/mods and /media/alice/x and " + "/media/alicia/y"), + QStringLiteral("/home/alice"), QStringLiteral("alice")) == + QStringLiteral( + "/run/media//SD/mods and /media//x and /media/alicia/y")); +} + +TEST_CASE("redact handles doubled backslashes from repr") { + const QString raw = QString::fromLatin1( + R"('C:\\Users\\Bob\\AppData' and 'D:\\Users\\Bob\\mods')"); + const QString result = redact(raw, QString::fromLatin1(R"(C:\Users\Bob)"), + QStringLiteral("Bob")); + CHECK_FALSE(result.contains(QStringLiteral("Bob"))); + CHECK(result.startsWith(QStringLiteral("'~"))); +} + +TEST_CASE("redact matches short usernames only in user directories") { + CHECK( + redact( + QString::fromLatin1( + R"(ed saw D:\Users\ed\mods and /Users/ED/x but not /Users/edgar)"), + QStringLiteral("/home/ed"), QStringLiteral("ed")) == + QString::fromLatin1( + R"(ed saw D:\Users\\mods and /Users//x but not /Users/edgar)")); +} + +TEST_CASE("redact leaves package ids that contain the username alone") { + const QString raw = QStringLiteral( + "Core (ludeon.rimworld) from /opt/rimworld; rimworld started"); + CHECK(redact(raw, QStringLiteral("/home/rimworld"), + QStringLiteral("rimworld")) == raw); +} + +TEST_CASE("read_log_tail reports a missing file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString content = readLogTail( + QDir(dir.path()).filePath(QStringLiteral("does_not_exist.log"))); + CHECK(content.contains(QStringLiteral(""))); +} + +TEST_CASE("read_log_tail reports an empty file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("empty.log")); + REQUIRE(writeBytes(path, "")); + CHECK(readLogTail(path).contains(QStringLiteral(""))); +} + +TEST_CASE("read_log_tail returns a small file unchanged") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("app.log")); + const char* text = "Line 1\nLine 2\nLine 3\n"; + REQUIRE(writeBytes(path, text)); + CHECK(readLogTail(path) == QString::fromLatin1(text)); +} + +TEST_CASE("read_log_tail unpacks loguru JSON records") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("json.log")); + REQUIRE(writeBytes( + path, + R"({"text": "2026-09-28 | INFO | Loaded mods\n", "record": {"id": 1}})" + "\n" + "Plain log line\n" + R"({"text": "2026-09-28 | INFO | Ready\n", "record": {"id": 2}})" + "\n")); + const QString content = readLogTail(path); + CHECK(content == QStringLiteral("2026-09-28 | INFO | Loaded mods\n" + "Plain log line\n" + "2026-09-28 | INFO | Ready\n")); + CHECK_FALSE(content.contains(QString::fromLatin1(R"({"text")"))); +} + +TEST_CASE("read_log_tail keeps the end of a large file and adds a banner") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = QDir(dir.path()).filePath(QStringLiteral("large.log")); + QStringList lines; + for (int i = 0; i < 200; ++i) { + lines.append(QStringLiteral("Log record number %1 with some extra padding") + .arg(i, 4, 10, QLatin1Char('0'))); + } + REQUIRE(writeBytes(path, lines.join(QLatin1Char('\n')).toStdString())); + + const QString content = readLogTail(path, 200); + CHECK(content.startsWith(QStringLiteral("[... truncated, showing last "))); + CHECK(content.contains(QStringLiteral(" of "))); + CHECK(content.contains(QStringLiteral("Log record number 0199"))); + CHECK_FALSE(content.contains(QStringLiteral("Log record number 0001"))); +} + +TEST_CASE( + "build_report contains every section with active mods in load order") { + TestApplication app; + QTemporaryDir homeDir; + REQUIRE(homeDir.isValid()); + QTemporaryDir logDir; + REQUIRE(logDir.isValid()); + + const QString home = + QDir(homeDir.path()).filePath(QStringLiteral("home/testuser")); + REQUIRE(QDir().mkpath(home)); + + ReportSource source; + source.gameVersion = QStringLiteral("1.5.4243"); + source.paths.game = + QDir(home).filePath(QStringLiteral("Games/RimWorld")).toStdString(); + source.paths.config_folder = + QDir(home).filePath(QStringLiteral(".config/unity3d")).toStdString(); + source.paths.local = + QDir(home).filePath(QStringLiteral("Games/RimWorld/Mods")).toStdString(); + source.allMods = { + {QStringLiteral("u1"), + aboutMod(QStringLiteral("Harmony"), QStringLiteral("brrainz.harmony"))}, + {QStringLiteral("u2"), + aboutMod(QStringLiteral("Core"), QStringLiteral("ludeon.rimworld"))}, + {QStringLiteral("u3"), + aboutMod(QStringLiteral("Royalty"), + QStringLiteral("ludeon.rimworld.royalty"))}, + }; + source.activeUuids = {QStringLiteral("u2"), QStringLiteral("u1"), + QStringLiteral("u3")}; + + const QString logPath = + QDir(logDir.path()).filePath(QStringLiteral("app.log")); + REQUIRE( + writeBytes(logPath, "INFO | Loaded /home/testuser/mods for testuser\n")); + + const QString report = + buildReport(source, logPath, home, QStringLiteral("testuser")); + + CHECK(report.contains(QStringLiteral("=== PxModRim System Report ==="))); + CHECK(report.contains(QStringLiteral("=== RimWorld Configuration ==="))); + CHECK(report.contains(QStringLiteral("=== Mod Collection ==="))); + CHECK(report.contains(QStringLiteral("=== Active Mods (Load Order) ==="))); + CHECK(report.contains(QStringLiteral("=== Application Log ==="))); + + CHECK(report.contains(QStringLiteral("App Version: ") + appVersion())); + CHECK(report.contains(QStringLiteral("Qt Version: "))); + CHECK(report.contains(QStringLiteral("OS: "))); + + CHECK(report.contains(QStringLiteral("Game Version: 1.5.4243"))); + const QString slashedReport = QString(report).replace(u'\\', u'/'); + CHECK(slashedReport.contains(QStringLiteral("~/Games/RimWorld"))); + CHECK(slashedReport.contains(QStringLiteral("~/.config/unity3d"))); + CHECK_FALSE(report.contains(home)); + + CHECK(report.contains(QStringLiteral("Total Mods: 3"))); + CHECK(report.contains(QStringLiteral("Active Mods: 3"))); + + CHECK(report.contains( + QStringLiteral(" 1. Core (ludeon.rimworld)\n" + " 2. Harmony (brrainz.harmony)\n" + " 3. Royalty (ludeon.rimworld.royalty)"))); + + CHECK( + report.contains(QStringLiteral("Loaded /home//mods for testuser"))); +} + +TEST_CASE("build_report names active uuids it cannot resolve") { + TestApplication app; + QTemporaryDir logDir; + REQUIRE(logDir.isValid()); + ReportSource source; + source.activeUuids = {QStringLiteral("/mods/gone")}; + + const QString logPath = + QDir(logDir.path()).filePath(QStringLiteral("app.log")); + REQUIRE(writeBytes(logPath, "x\n")); + const QString report = buildReport(source, logPath); + CHECK(report.contains(QStringLiteral(" 1. Unknown Mod (/mods/gone)"))); + CHECK(report.contains(QStringLiteral("Total Mods: 0"))); +} + +TEST_CASE("PasteUploader posts the report and returns the URL") { + TestApplication app; + CannedServer server(httpResponse("201 Created", "https://paste.rs/abc123\n")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + + const Result result = + wait(uploader.upload(QStringLiteral("Report text payload"))); + REQUIRE(result.has_value()); + CHECK(*result == QStringLiteral("https://paste.rs/abc123")); + + const QByteArray request = server.request(); + CHECK(request.contains("User-Agent: PxModRim/")); + CHECK(request.contains("Content-Type: text/plain; charset=utf-8")); + CHECK(request.endsWith("Report text payload")); +} + +TEST_CASE("PasteUploader rejects empty text without sending it") { + TestApplication app; + QNetworkAccessManager network; + PasteUploader uploader(network, QStringLiteral("http://127.0.0.1:9/")); + requireError(wait(uploader.upload(QStringLiteral(" "))), + QStringLiteral("Report text is empty")); +} + +TEST_CASE("PasteUploader reports an HTTP error status") { + TestApplication app; + CannedServer server(httpResponse("403 Forbidden", "nope")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("HTTP 403 Forbidden")); +} + +TEST_CASE("PasteUploader reports a server error status") { + TestApplication app; + CannedServer server(httpResponse("500 Internal Server Error", "boom")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("HTTP 500 Internal Server Error")); +} + +TEST_CASE("PasteUploader rejects a success body that is not a URL") { + TestApplication app; + CannedServer server( + httpResponse("200 OK", "Something unexpected went wrong")); + QNetworkAccessManager network; + PasteUploader uploader(network, server.url()); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("valid URL")); +} + +TEST_CASE("PasteUploader reports a refused connection as a network error") { + TestApplication app; + // Listen and close to obtain a port that nothing is listening on. + QTcpServer closed; + REQUIRE(closed.listen(QHostAddress::LocalHost)); + const QString url = + QStringLiteral("http://127.0.0.1:%1/").arg(closed.serverPort()); + closed.close(); + + QNetworkAccessManager network; + PasteUploader uploader(network, url); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("network error")); +} + +TEST_CASE("PasteUploader reports a request that gets no answer as a timeout") { + TestApplication app; + CannedServer server{QByteArray{}}; + QNetworkAccessManager network; + PasteUploader uploader(network, server.url(), std::chrono::milliseconds(200)); + requireError(wait(uploader.upload(QStringLiteral("Report content"))), + QStringLiteral("timed out")); +} diff --git a/tests/core/test_config.cpp b/tests/core/test_config.cpp new file mode 100644 index 0000000..f1b6bf3 --- /dev/null +++ b/tests/core/test_config.cpp @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/constants.hpp" +#include "core/file_io.hpp" +#include "test_support.hpp" + +namespace config_test { + +using namespace px::modrim; +using test_support::readBytes; +using test_support::runSync; +using test_support::writeBytes; + +// Stands in for ui_prefs.json's struct: these tests exercise ConfigService's +// versioning, not the UI settings themselves. +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on disk or +// on the wire +struct TestPrefs { + bool desc_expanded = false; + LaunchStrategy launch_strategy = LaunchStrategy::kDirect; + std::string skipped_update_tag; + + bool operator==(const TestPrefs&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +QString pathIn(const QTemporaryDir& dir, const char* name) { + return dir.filePath(QString::fromLatin1(name)); +} + +QStringList backupsOf(const QTemporaryDir& dir, const char* name) { + return QDir(dir.path()) + .entryList({QString::fromLatin1(name) + QStringLiteral(".bak.*")}, + QDir::Files); +} + +int schemaVersionOf(const QString& path) { + glz::json_t document; + REQUIRE_FALSE(glz::read_json(document, readBytes(path))); + const auto& object = document.get_object(); + const auto marker = object.find(std::string(kSchemaVersionKey)); + REQUIRE(marker != object.end()); + return static_cast(marker->second.get()); +} + +AppConfig legacyExpectedConfig() { + AppConfig cfg; + cfg.paths = PathConfig{ + .game = "/legacy/game", + .local = "/legacy/mods", + .workshop = "/legacy/workshop", + .config_folder = "/legacy/config", + .community_rules_file = "/legacy/rules", + .no_version_warning_file = "/legacy/no-warning", + .use_this_instead_file = "/legacy/use-instead", + }; + return cfg; +} + +} // namespace config_test + +using namespace config_test; + +TEST_CASE("read_game_version reads and trims Version.txt") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); + + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), " 1.6.4871 rev598 \n")); + REQUIRE(readGameVersion(dir.path()) == QStringLiteral("1.6.4871 rev598")); +} + +TEST_CASE( + "read_game_version returns nullopt when Version.txt is missing or " + "unreadable") { +#ifdef Q_OS_WIN + SKIP("Windows has no POSIX read permission to remove"); +#endif + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); + + REQUIRE(writeBytes(pathIn(dir, "Version.txt"), "1.6.4871 rev598\n")); + REQUIRE(QFile(pathIn(dir, "Version.txt")) + .setPermissions(QFileDevice::Permissions{})); + REQUIRE_FALSE(readGameVersion(dir.path()).has_value()); +} + +TEST_CASE("legacy app config migrates with a backup") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + const std::string legacy = + R"({"paths":{"game":"/legacy/game","local":"/legacy/mods","workshop":"/legacy/workshop",)" + R"("config_folder":"/legacy/config","community_rules_file":"/legacy/rules",)" + R"("no_version_warning_file":"/legacy/no-warning","use_this_instead_file":"/legacy/use-instead",)" + R"("steamcmd_prefix":"/legacy/steamcmd"}})"; + REQUIRE(writeBytes(path, legacy)); + + ConfigService service(dir.path()); + AppConfigService app(service); + REQUIRE(runSync(app.setup()).has_value()); + + REQUIRE(app.cfg() == legacyExpectedConfig()); + REQUIRE(schemaVersionOf(path) == 1); + const auto backups = backupsOf(dir, "config.json"); + REQUIRE(backups.size() == 1); + REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); +} + +TEST_CASE("legacy ui prefs migrate with a backup") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "ui_prefs.json"); + const std::string legacy = + R"({"validate_downloads":true,"desc_expanded":true,"launch_strategy":1})"; + REQUIRE(writeBytes(path, legacy)); + + ConfigService service(dir.path()); + const auto prefs = service.load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(*prefs == TestPrefs{.desc_expanded = true, + .launch_strategy = LaunchStrategy::kSteam, + .skipped_update_tag = ""}); + REQUIRE(schemaVersionOf(path) == 1); + const auto backups = backupsOf(dir, "ui_prefs.json"); + REQUIRE(backups.size() == 1); + REQUIRE(readBytes(dir.filePath(backups.first())) == legacy); +} + +TEST_CASE("fresh services return defaults without creating files") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + AppConfigService app(service); + REQUIRE(runSync(app.setup()).has_value()); + + REQUIRE(app.cfg() == AppConfig{}); + REQUIRE(service.load("ui_prefs.json") == TestPrefs{}); + REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "config.json"))); + REQUIRE_FALSE(QFileInfo::exists(pathIn(dir, "ui_prefs.json"))); +} + +TEST_CASE("managed saves stamp schema_version and round-trip values") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + AppConfig expectedConfig; + expectedConfig.paths.game = "/game"; + expectedConfig.paths.local = "/mods"; + const TestPrefs expectedPrefs{.desc_expanded = true, + .launch_strategy = LaunchStrategy::kSteam, + .skipped_update_tag = ""}; + + REQUIRE(service.save("config.json", expectedConfig).has_value()); + REQUIRE( + readBytes(pathIn(dir, "config.json")).find("\"schema_version\": 1\n}") != + std::string::npos); + REQUIRE(service.save("ui_prefs.json", expectedPrefs).has_value()); + + REQUIRE(schemaVersionOf(pathIn(dir, "config.json")) == 1); + REQUIRE(schemaVersionOf(pathIn(dir, "ui_prefs.json")) == 1); + REQUIRE(service.load("config.json") == expectedConfig); + REQUIRE(service.load("ui_prefs.json") == expectedPrefs); +} + +TEST_CASE("current config load does not rewrite the file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE( + writeBytes(path, R"({"schema_version":1,"paths":{"game":"/current"}})")); + const std::string before = readBytes(path); + + const auto cfg = ConfigService(dir.path()).load("config.json"); + REQUIRE(cfg.has_value()); + REQUIRE(cfg->paths.game == "/current"); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "config.json").isEmpty()); +} + +TEST_CASE("current ui prefs load does not rewrite the file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "ui_prefs.json"); + REQUIRE(writeBytes(path, R"({"schema_version":1,"desc_expanded":true})")); + const std::string before = readBytes(path); + + const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(prefs->desc_expanded); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "ui_prefs.json").isEmpty()); +} + +TEST_CASE("corrupt config returns safe defaults") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes(pathIn(dir, "config.json"), "{invalid")); + REQUIRE(ConfigService(dir.path()).load("config.json") == + AppConfig{}); +} + +TEST_CASE("unreadable config returns safe defaults") { +#ifdef Q_OS_WIN + SKIP("Windows has no POSIX read permission to remove"); +#endif + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, "{}")); + REQUIRE(QFile(path).setPermissions(QFileDevice::Permissions{})); + REQUIRE(ConfigService(dir.path()).load("config.json") == + AppConfig{}); +} + +TEST_CASE("missing JSON migration step fails without rewrite") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"paths":{"game":"/legacy"}})")); + const std::string before = readBytes(path); + + const ConfigService service(dir.path(), JsonMigrations{}); + const auto loaded = service.load("config.json"); + REQUIRE_FALSE(loaded.has_value()); + REQUIRE(loaded.error().message.contains("Missing JSON migration step")); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir, "config.json").isEmpty()); +} + +TEST_CASE("unsupported config schema fails without mutation") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = pathIn(dir, "config.json"); + REQUIRE(writeBytes(path, R"({"schema_version":2})")); + const std::string before = readBytes(path); + + const auto loaded = ConfigService(dir.path()).load("config.json"); + REQUIRE_FALSE(loaded.has_value()); + REQUIRE(loaded.error().message.contains("Unsupported JSON schema version")); + REQUIRE(readBytes(path) == before); +} + +TEST_CASE("ui prefs skipped update tag round-trips") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + REQUIRE(service + .save("ui_prefs.json", + TestPrefs{.desc_expanded = false, + .launch_strategy = LaunchStrategy::kDirect, + .skipped_update_tag = "v0.2.0"}) + .has_value()); + REQUIRE(service.load("ui_prefs.json").value().skipped_update_tag == + "v0.2.0"); +} + +TEST_CASE("ui prefs with removed keys still load") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes( + pathIn(dir, "ui_prefs.json"), + R"({"schema_version":1,"deps_expanded":true,"desc_expanded":true,)" + R"("skipped_update_tag":"v0.3.0"})")); + + const auto prefs = ConfigService(dir.path()).load("ui_prefs.json"); + REQUIRE(prefs.has_value()); + REQUIRE(prefs->desc_expanded); + REQUIRE(prefs->skipped_update_tag == "v0.3.0"); +} diff --git a/tests/core/test_context.cpp b/tests/core/test_context.cpp new file mode 100644 index 0000000..b713164 --- /dev/null +++ b/tests/core/test_context.cpp @@ -0,0 +1,147 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/models/metadata/structures.hpp" +#include "services/services_support.hpp" + +using px::modrim::AboutXmlMod; +using px::modrim::AppConfig; +using px::modrim::CaseInsensitiveStr; +using px::modrim::CoreContext; +using px::modrim::DependencyMod; +using px::modrim::ListedModPtr; +using services_test::EventLoop; +using services_test::runAsync; + +namespace { + +std::shared_ptr about(const QString& packageId, + bool valid = true) { + auto mod = std::make_shared(); + mod->name = packageId; + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("local"); + mod->valid = valid; + return mod; +} + +std::unique_ptr makeContext(const QString& gamePath = {}) { + static QTemporaryDir configDir; + static px::modrim::ConfigService configService(configDir.path()); + AppConfig cfg; + cfg.paths.game = gamePath.toStdString(); + return CoreContext::create(cfg, &configService); +} + +void writeVersion(const QString& gameDir, const QString& version) { + QFile file(QDir(gameDir).filePath(QStringLiteral("Version.txt"))); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(version.toUtf8()); +} + +} // namespace + +TEST_CASE("target version is the major.minor of the game version") { + QTemporaryDir game; + REQUIRE(game.isValid()); + + auto ctx = makeContext(); + CHECK(ctx->gameVersion() == QStringLiteral("Unknown")); + CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); + + writeVersion(game.path(), QStringLiteral("1.5.4297 rev123")); + ctx->updateConfig([&] { + AppConfig cfg = ctx->config(); + cfg.paths.game = game.path().toStdString(); + return cfg; + }()); + CHECK(ctx->gameVersion() == QStringLiteral("1.5.4297 rev123")); + CHECK(ctx->targetVersion() == QStringLiteral("1.5")); +} + +TEST_CASE("a game version without a minor part has no target version") { + QTemporaryDir game; + REQUIRE(game.isValid()); + writeVersion(game.path(), QStringLiteral("1")); + + auto ctx = makeContext(game.path()); + CHECK(ctx->gameVersion() == QStringLiteral("1")); + CHECK(ctx->targetVersion() == QStringLiteral("Unknown")); +} + +TEST_CASE("setActive emits on every change and keeps the loaded list") { + EventLoop loop; + auto ctx = makeContext(); + ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, + {QStringLiteral("b"), about(QStringLiteral("b.mod"))}}, + {QStringLiteral("a")}); + + std::vector events; + const auto id = ctx->activeStateChanged().connect( + [&events](const QStringList& active) { events.push_back(active); }); + + ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); + REQUIRE(events.size() == 1); + CHECK(events.back() == QStringList{"a", "b"}); + + ctx->setActive({QStringLiteral("a"), QStringLiteral("b")}); + CHECK(events.size() == 1); + + ctx->setActive({QStringLiteral("b"), QStringLiteral("a")}); + REQUIRE(events.size() == 2); + CHECK(ctx->activeUuids() == QStringList{"b", "a"}); + CHECK(ctx->loadedActiveUuids() == QStringList{"a"}); + + CHECK(ctx->activeStateChanged().disconnect(id)); +} + +TEST_CASE("computeStats counts total, active, inactive and invalid mods") { + EventLoop loop; + auto ctx = makeContext(); + ctx->load({{QStringLiteral("a"), about(QStringLiteral("a.mod"))}, + {QStringLiteral("b"), about(QStringLiteral("b.mod"))}, + {QStringLiteral("c"), about(QStringLiteral("c.mod"), false)}}, + {QStringLiteral("a")}); + + const auto stats = ctx->computeStats(); + CHECK(stats.total == 3); + CHECK(stats.active == 1); + CHECK(stats.inactive == 2); + CHECK(stats.errors == 1); + + const auto overrideStats = ctx->computeStats(QStringList{"a", "b"}); + CHECK(overrideStats.active == 2); + CHECK(overrideStats.inactive == 1); +} + +TEST_CASE("autoSort enables a missing dependency and orders it first") { + EventLoop loop; + auto dependent = about(QStringLiteral("mod.a")); + DependencyMod dependency; + dependency.name = QStringLiteral("Mod B"); + dependency.packageId = CaseInsensitiveStr(QStringLiteral("mod.b")); + dependent->aboutRules.dependencies.append(std::move(dependency)); + + auto ctx = makeContext(); + ctx->load({{QStringLiteral("uuid-a"), dependent}, + {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, + {QStringLiteral("uuid-a")}); + + const auto [count, elapsedMs] = runAsync(ctx->autoSort()); + CHECK(count == 2); + CHECK(elapsedMs >= 0.0); + REQUIRE(ctx->activeUuids().size() == 2); + CHECK(ctx->activeUuids().indexOf(QStringLiteral("uuid-b")) < + ctx->activeUuids().indexOf(QStringLiteral("uuid-a"))); +} diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp new file mode 100644 index 0000000..fb3d7fa --- /dev/null +++ b/tests/core/test_loading.cpp @@ -0,0 +1,89 @@ +#include +#include +#include +#include + +#include + +#include "core/loading.hpp" + +namespace { + +struct Emission { + QString status; + int pct; + int current; + int total; + + bool operator==(const Emission&) const = default; +}; + +} // namespace + +TEST_CASE( + "loading state reports the innermost frame and pops back to its parent") { + px::modrim::LoadingState state; + QList emissions; + int finished = 0; + QObject::connect(&state, &px::modrim::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + QObject::connect(&state, &px::modrim::LoadingState::finished, + [&] { ++finished; }); + + { + auto outer = state.task(QStringLiteral("outer"), 4); + REQUIRE(state.statuses() == QStringList{"outer"}); + state.setProgress(1); + { + auto inner = state.task(QStringLiteral("inner")); + REQUIRE(state.statuses() == QStringList{"outer", "inner"}); + state.step(29); + } + REQUIRE(state.statuses() == QStringList{"outer"}); + } + + REQUIRE(state.statuses().isEmpty()); + REQUIRE(finished == 1); + REQUIRE(emissions.last() == Emission{QStringLiteral("outer"), 25, 1, 4}); + REQUIRE(emissions.contains(Emission{QStringLiteral("inner"), 28, 29, 100})); +} + +TEST_CASE("percent truncates the float quotient, as the legacy progress did") { + px::modrim::LoadingState state; + QList emissions; + QObject::connect(&state, &px::modrim::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + state.push(QStringLiteral("p"), 100); + state.setProgress(29); + REQUIRE(emissions.last() == Emission{QStringLiteral("p"), 28, 29, 100}); +} + +TEST_CASE( + "set_total clamps to at least one step and progress stays within bounds") { + px::modrim::LoadingState state; + QList emissions; + QObject::connect(&state, &px::modrim::LoadingState::changed, + [&](const QString& status, int pct, int current, int total) { + emissions.append(Emission{status, pct, current, total}); + }); + state.push(QStringLiteral("t"), 10); + state.setTotal(0); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); + state.setProgress(50); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 100, 1, 1}); + state.setProgress(-5); + REQUIRE(emissions.last() == Emission{QStringLiteral("t"), 0, 0, 1}); +} + +TEST_CASE("popping an empty stack still reports completion") { + px::modrim::LoadingState state; + int finished = 0; + QObject::connect(&state, &px::modrim::LoadingState::finished, + [&] { ++finished; }); + state.pop(); + REQUIRE(finished == 1); +} diff --git a/tests/core/test_migrator.cpp b/tests/core/test_migrator.cpp new file mode 100644 index 0000000..d9415bd --- /dev/null +++ b/tests/core/test_migrator.cpp @@ -0,0 +1,253 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/migrator.hpp" +#include "test_support.hpp" + +namespace { + +using px::modrim::ensureSchema; +using px::modrim::MigrationSteps; +using px::modrim::Result; +using test_support::readBytes; + +// Owns one named SQLite connection and closes it when it goes out of scope. +class Database { + public: + Database(const QString& path, QString name) : m_name(std::move(name)) { + m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_name); + m_db.setDatabaseName(path); + REQUIRE(m_db.open()); + } + + ~Database() { + m_db.close(); + m_db = QSqlDatabase(); + QSqlDatabase::removeDatabase(m_name); + } + + Database(const Database&) = delete; + Database& operator=(const Database&) = delete; + + QSqlDatabase& get() { return m_db; } + + void exec(const QString& sql) { + QSqlQuery query(m_db); + REQUIRE(query.exec(sql)); + } + + QStringList column(const QString& sql) { + QSqlQuery query(m_db); + REQUIRE(query.exec(sql)); + QStringList result; + while (query.next()) { + result.append(query.value(0).toString()); + } + return result; + } + + int userVersion() { + QSqlQuery query(m_db); + REQUIRE(query.exec(QStringLiteral("PRAGMA user_version"))); + REQUIRE(query.next()); + return query.value(0).toInt(); + } + + private: + QSqlDatabase m_db; + QString m_name; +}; + +QStringList backupsOf(const QString& dir, const QString& dbName) { + return QDir(dir).entryList({dbName + QStringLiteral(".bak.*")}, QDir::Files); +} + +Result noop() { return {}; } + +} // namespace + +TEST_CASE("fresh database is initialised and stamped without running steps") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + bool stepped = false; + const MigrationSteps steps{ + {1, noop}, + {2, + [&]() -> Result { + stepped = true; + return {}; + }}, + }; + { + Database db(path, QStringLiteral("fresh")); + REQUIRE(ensureSchema(db.get(), + QStringLiteral("CREATE TABLE state (value TEXT " + "DEFAULT 'a;b'); -- note; ignored\n" + "CREATE TABLE other (x INTEGER);"), + 2, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 2); + REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE " + "type = 'table' AND name = 'state'")) == + QStringList{"state"}); + REQUIRE(db.column(QStringLiteral("SELECT name FROM sqlite_master WHERE " + "type = 'table' AND name = 'other'")) == + QStringList{"other"}); + } + REQUIRE_FALSE(stepped); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); +} + +TEST_CASE("migrations checkpoint the WAL before backing up") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + QStringList events; + { + Database db(path, QStringLiteral("wal")); + REQUIRE(db.column(QStringLiteral("PRAGMA journal_mode = WAL")) == + QStringList{"wal"}); + db.exec(QStringLiteral("PRAGMA wal_autocheckpoint = 0")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("INSERT INTO state VALUES ('before')")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + REQUIRE(QFileInfo(path + QStringLiteral("-wal")).size() > 0); + + const MigrationSteps steps{ + {2, + [&]() -> Result { + events.append(QStringLiteral("two")); + db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN second TEXT")); + return {}; + }}, + {3, + [&]() -> Result { + events.append(QStringLiteral("three")); + db.exec(QStringLiteral("ALTER TABLE state ADD COLUMN third TEXT")); + return {}; + }}, + }; + REQUIRE(ensureSchema(db.get(), + QStringLiteral( + "CREATE TABLE IF NOT EXISTS state (value TEXT);"), + 3, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 3); + REQUIRE(db.column(QStringLiteral( + "SELECT name FROM pragma_table_info('state')")) == + QStringList{"value", "second", "third"}); + REQUIRE(events == QStringList{"two", "three"}); + } + + const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); + REQUIRE(found.size() == 1); + const QString backup = dir.filePath(found.first()); + { + Database backupDb(backup, QStringLiteral("wal-backup")); + REQUIRE(backupDb.userVersion() == 1); + REQUIRE(backupDb.column(QStringLiteral( + "SELECT name FROM pragma_table_info('state')")) == + QStringList{"value"}); + REQUIRE(backupDb.column(QStringLiteral("SELECT value FROM state")) == + QStringList{"before"}); + } + for (const char* suffix : {"-wal", "-shm"}) { + QFile::remove(backup + QString::fromLatin1(suffix)); + } +} + +TEST_CASE("missing migration step fails without advancing the version") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + { + Database db(path, QStringLiteral("missing")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + before = readBytes(path); + const auto result = ensureSchema( + db.get(), + QStringLiteral("CREATE TABLE IF NOT EXISTS state (value TEXT);"), 3, + MigrationSteps{{2, noop}}, path); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Missing migration step")); + REQUIRE(db.userVersion() == 1); + } + + REQUIRE(readBytes(path) == before); + const auto found = backupsOf(dir.path(), QStringLiteral("cache.db")); + REQUIRE(found.size() == 1); + REQUIRE(readBytes(dir.filePath(found.first())) == before); +} + +TEST_CASE("current schema is a no-op") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + bool called = false; + { + Database db(path, QStringLiteral("current")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 1")); + before = readBytes(path); + const MigrationSteps steps{{1, [&]() -> Result { + called = true; + return {}; + }}}; + REQUIRE( + ensureSchema( + db.get(), + QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), + 1, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 1); + } + REQUIRE_FALSE(called); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); +} + +TEST_CASE("newer schema is a no-op") { + test_support::TestApplication app; + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("cache.db")); + std::string before; + bool called = false; + { + Database db(path, QStringLiteral("newer")); + db.exec(QStringLiteral("CREATE TABLE state (value TEXT)")); + db.exec(QStringLiteral("PRAGMA user_version = 2")); + before = readBytes(path); + const MigrationSteps steps{{1, [&]() -> Result { + called = true; + return {}; + }}}; + REQUIRE( + ensureSchema( + db.get(), + QStringLiteral("CREATE TABLE should_not_be_created (value TEXT);"), + 1, steps, path) + .has_value()); + REQUIRE(db.userVersion() == 2); + } + REQUIRE_FALSE(called); + REQUIRE(readBytes(path) == before); + REQUIRE(backupsOf(dir.path(), QStringLiteral("cache.db")).isEmpty()); +} diff --git a/tests/core/test_mods_config.cpp b/tests/core/test_mods_config.cpp new file mode 100644 index 0000000..47672e7 --- /dev/null +++ b/tests/core/test_mods_config.cpp @@ -0,0 +1,392 @@ +#include +#include +#include +#include +#include + +#include +#include + +#include "core/file_io.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/mods_config.hpp" +#include "test_support.hpp" + +namespace { + +using namespace px::modrim; +using test_support::readBytes; +using test_support::writeBytes; + +constexpr auto kSampleXml = R"( + + 1.6.4871 rev600 + +
  • ludeon.rimworld
  • +
  • ludeon.rimworld.royalty
  • +
  • ludeon.rimworld.ideology
  • +
  • ludeon.rimworld.biotech
  • +
  • ludeon.rimworld.anomaly
  • +
  • ludeon.rimworld.odyssey
  • +
    + +
  • ludeon.rimworld.royalty
  • +
  • ludeon.rimworld.ideology
  • +
  • ludeon.rimworld.biotech
  • +
  • ludeon.rimworld.anomaly
  • +
  • ludeon.rimworld.odyssey
  • +
    +
    +)"; + +CaseInsensitiveStr pid(const char* text) { + return CaseInsensitiveStr(QString::fromLatin1(text)); +} + +std::optional parseText(const std::string& content, + const QTemporaryDir& dir) { + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeBytes(path, content)); + return parseModsConfig(path); +} + +ModsConfig modsConfig(const char* packageId) { + return ModsConfig{ + .version = QStringLiteral("1.5"), + .activeMods = {pid(packageId)}, + .knownExpansions = {}, + }; +} + +// Package ids of the snapshots in `dir`, newest first. +QList retainedIds(const QString& dir) { + QList ids; + for (const auto& snapshot : getSnapshots(dir)) { + const auto parsed = parseModsConfig(snapshot); + REQUIRE(parsed.has_value()); + ids.append(parsed->activeMods.first()); + } + return ids; +} + +} // namespace + +TEST_CASE("parse_mods_config reads version, active mods and expansions") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parseText(kSampleXml, dir); + REQUIRE(config.has_value()); + REQUIRE(config->version == QStringLiteral("1.6.4871 rev600")); + REQUIRE(config->activeMods == QList{ + pid("ludeon.rimworld"), + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + pid("ludeon.rimworld.odyssey"), + }); + REQUIRE(config->knownExpansions == QList{ + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + pid("ludeon.rimworld.odyssey"), + }); +} + +TEST_CASE("parse_mods_config accepts a single active mod") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parseText(R"( + + 1.5 + +
  • ludeon.rimworld
  • +
    + +
  • ludeon.rimworld.royalty
  • +
    +
    )", + dir); + REQUIRE(config.has_value()); + REQUIRE(config->activeMods == + QList{pid("ludeon.rimworld")}); + REQUIRE(config->knownExpansions == + QList{pid("ludeon.rimworld.royalty")}); +} + +TEST_CASE( + "parse_mods_config falls back to the DLC list when no expansions are " + "declared") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const auto config = parseText(R"( + + 1.5 + +)", + dir); + REQUIRE(config.has_value()); + REQUIRE(config->activeMods.isEmpty()); + REQUIRE_FALSE(config->knownExpansions.isEmpty()); +} + +TEST_CASE("parse_mods_config returns nullopt for a missing file") { + REQUIRE_FALSE(parseModsConfig(QStringLiteral("/nonexistent/ModsConfig.xml")) + .has_value()); +} + +TEST_CASE("write then parse round-trips the mod lists") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const ModsConfig original{ + .version = QStringLiteral("1.6.4871 rev600"), + .activeMods = + { + pid("ludeon.rimworld"), + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + }, + .knownExpansions = + { + pid("ludeon.rimworld.royalty"), + pid("ludeon.rimworld.ideology"), + pid("ludeon.rimworld.biotech"), + pid("ludeon.rimworld.anomaly"), + }, + }; + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE( + writeModsConfig(path, original, dir.filePath(QStringLiteral("snapshots"))) + .has_value()); + + const auto loaded = parseModsConfig(path); + REQUIRE(loaded.has_value()); + REQUIRE(loaded->version == original.version); + REQUIRE(loaded->activeMods == original.activeMods); + REQUIRE(loaded->knownExpansions == original.knownExpansions); +} + +TEST_CASE("write keeps unknown XML in an existing ModsConfigData document") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("ModsConfig.xml")); + std::string text = kSampleXml; + text.replace( + text.find(""), std::string("").size(), + R"(42)"); + REQUIRE(writeBytes(path, text)); + + auto loaded = parseModsConfig(path); + REQUIRE(loaded.has_value()); + loaded->activeMods = {pid("author.updated")}; + REQUIRE( + writeModsConfig(path, *loaded, dir.filePath(QStringLiteral("snapshots"))) + .has_value()); + + pugi::xml_document doc; + REQUIRE(doc.load_file(path.toStdString().c_str())); + const pugi::xml_node root = doc.document_element(); + REQUIRE(std::string(root.attribute("custom").value()) == "preserve"); + REQUIRE(std::string(root.child("otherSetting").child_value("value")) == "42"); + REQUIRE( + std::string(root.child("otherSetting").attribute("enabled").value()) == + "true"); + REQUIRE(std::string(root.child("activeMods").child_value("li")) == + "author.updated"); +} + +TEST_CASE("replacing a config keeps the previous contents as a snapshot") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + + REQUIRE(writeModsConfig(configPath, modsConfig("mod.alpha"), snapshotsDir) + .has_value()); + REQUIRE(getSnapshots(snapshotsDir).isEmpty()); + + REQUIRE(writeModsConfig(configPath, modsConfig("mod.beta"), snapshotsDir) + .has_value()); + + const auto snapshots = getSnapshots(snapshotsDir); + REQUIRE(snapshots.size() == 1); + const auto previous = parseModsConfig(snapshots.first()); + const auto current = parseModsConfig(configPath); + REQUIRE(previous.has_value()); + REQUIRE(previous->activeMods == QList{pid("mod.alpha")}); + REQUIRE(current.has_value()); + REQUIRE(current->activeMods == QList{pid("mod.beta")}); +} + +TEST_CASE("replacement retains only the newest ten snapshots") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir) + .has_value()); + + for (int index = 1; index < 12; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir) + .has_value()); + } + + QList expected; + for (int index = 10; index >= 1; --index) { + expected.append(pid(("mod." + std::to_string(index)).c_str())); + } + REQUIRE(getSnapshots(snapshotsDir).size() == 10); + REQUIRE(retainedIds(snapshotsDir) == expected); +} + +TEST_CASE("configurable retention limit is honored") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, 3) + .has_value()); + + for (int index = 1; index < 6; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, 3) + .has_value()); + } + + const auto snapshots = getSnapshots(snapshotsDir); + REQUIRE(snapshots.size() == 3); + REQUIRE(retainedIds(snapshotsDir) == + QList{pid("mod.4"), pid("mod.3"), pid("mod.2")}); + + // Lowering the limit on duplicate content prunes the excess before returning. + REQUIRE(writeBytes(configPath, readBytes(snapshots.first()))); + const auto duplicate = createSnapshot(configPath, snapshotsDir, 2); + REQUIRE(duplicate.has_value()); + REQUIRE(duplicate->has_value()); + REQUIRE(**duplicate == snapshots.first()); + REQUIRE(getSnapshots(snapshotsDir).size() == 2); +} + +TEST_CASE( + "restoring the oldest snapshot at full retention keeps it and backs up the " + "current file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + constexpr int kMaxSnapshots = 3; + REQUIRE(writeModsConfig(configPath, modsConfig("mod.0"), snapshotsDir, + kMaxSnapshots) + .has_value()); + for (int index = 1; index < 4; ++index) { + const std::string id = "mod." + std::to_string(index); + REQUIRE(writeModsConfig(configPath, modsConfig(id.c_str()), snapshotsDir, + kMaxSnapshots) + .has_value()); + } + + const QString selected = getSnapshots(snapshotsDir).last(); + const std::string selectedData = readBytes(selected); + + REQUIRE(restoreSnapshot(selected, configPath, snapshotsDir, kMaxSnapshots) + .has_value()); + + const auto restored = parseModsConfig(configPath); + REQUIRE(restored.has_value()); + REQUIRE(restored->activeMods == QList{pid("mod.0")}); + REQUIRE(QFileInfo::exists(selected)); + REQUIRE(readBytes(selected) == selectedData); + REQUIRE(getSnapshots(snapshotsDir).size() == kMaxSnapshots); + REQUIRE(retainedIds(snapshotsDir) == + QList{pid("mod.3"), pid("mod.2"), pid("mod.0")}); +} + +TEST_CASE( + "restoring a snapshot writes its exact bytes and backs up the current " + "file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.current"), snapshotsDir) + .has_value()); + + const QString snapshotPath = + dir.filePath(QStringLiteral("snapshots/ModsConfig_20260922_100000.xml")); + const std::string snapshotData = + "\n" + "1.5" + "
  • mod.saved
  • \n"; + REQUIRE(QDir().mkpath(dir.filePath(QStringLiteral("snapshots")))); + REQUIRE(writeBytes(snapshotPath, snapshotData)); + + REQUIRE(restoreSnapshot(snapshotPath, configPath, snapshotsDir).has_value()); + REQUIRE(readBytes(configPath) == snapshotData); + + QStringList backups; + for (const auto& snapshot : getSnapshots(snapshotsDir)) { + if (QFileInfo(snapshot) != QFileInfo(snapshotPath)) { + backups.append(snapshot); + } + } + REQUIRE(backups.size() == 1); + const auto backup = parseModsConfig(backups.first()); + REQUIRE(backup.has_value()); + REQUIRE(backup->activeMods == QList{pid("mod.current")}); +} + +TEST_CASE("invalid snapshots never change the current file") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir) + .has_value()); + const std::string original = readBytes(configPath); + + const QString missing = + dir.filePath(QStringLiteral("snapshots/ModsConfig_missing.xml")); + const QString corrupt = + dir.filePath(QStringLiteral("snapshots/ModsConfig_corrupt.xml")); + const QString wrongRoot = + dir.filePath(QStringLiteral("snapshots/ModsConfig_wrong_root.xml")); + const QString missingActive = dir.filePath( + QStringLiteral("snapshots/ModsConfig_missing_active_mods.xml")); + REQUIRE(QDir().mkpath(snapshotsDir)); + REQUIRE(writeBytes(corrupt, "not XML")); + REQUIRE(writeBytes(wrongRoot, "")); + REQUIRE( + writeBytes(missingActive, + "1.5")); + + for (const QString& snapshot : {missing, corrupt, wrongRoot, missingActive}) { + const QStringList before = getSnapshots(snapshotsDir); + REQUIRE_FALSE( + restoreSnapshot(snapshot, configPath, snapshotsDir).has_value()); + REQUIRE(readBytes(configPath) == original); + REQUIRE(getSnapshots(snapshotsDir) == before); + } +} + +TEST_CASE( + "a failed replacement reports an error and leaves no temporary files") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeModsConfig(configPath, modsConfig("mod.safe"), snapshotsDir) + .has_value()); + + // A directory at the target makes the final rename fail after the temporary + // file is written. + const QString blocked = dir.filePath(QStringLiteral("Blocked.xml")); + REQUIRE(QDir().mkpath(blocked)); + REQUIRE_FALSE(writeModsConfig(blocked, modsConfig("mod.new"), snapshotsDir) + .has_value()); + REQUIRE(QDir(dir.path()).entryList(QDir::Files | QDir::Hidden) == + QStringList{QStringLiteral("ModsConfig.xml")}); +} diff --git a/tests/core/test_plugin.cpp b/tests/core/test_plugin.cpp new file mode 100644 index 0000000..d980585 --- /dev/null +++ b/tests/core/test_plugin.cpp @@ -0,0 +1,144 @@ +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/plugin.hpp" +#include "test_support.hpp" + +// Stand-in for a composition root's context: records what each plugin did. +namespace { + +struct TestContext { + QStringList log; +}; + +using Plugin = px::modrim::BasicPlugin; +using PluginRegistry = px::modrim::BasicPluginRegistry; +using test_support::runSync; + +class RecorderPlugin : public Plugin { + public: + RecorderPlugin(QString name, QStringList deps = {}, + QStringList* shutdownLog = nullptr) + : Plugin(std::move(name), std::move(deps)), m_shutdownLog(shutdownLog) {} + + void setup(TestContext& ctx) override { ctx.log.append(name()); } + + QCoro::Task init(TestContext& ctx) override { + ctx.log.append(name()); + co_return; + } + + QCoro::Task shutdown() override { + if (m_shutdownLog) { + m_shutdownLog->append(name()); + } + co_return; + } + + private: + QStringList* m_shutdownLog; +}; + +} // namespace + +TEST_CASE("empty registry sets up nothing") { + PluginRegistry reg; + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log.isEmpty()); +} + +TEST_CASE("plugins without dependencies set up in name order") { + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b")); + reg.registerPlugin(std::make_unique("c")); + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); +} + +TEST_CASE("linear dependency chain sets up in order") { + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"b"})); + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); +} + +TEST_CASE("complex DAG sets up level by level, names sorted within a level") { + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"a"})); + reg.registerPlugin( + std::make_unique("d", QStringList{"b", "c"})); + reg.registerPlugin(std::make_unique("e", QStringList{"d"})); + reg.registerPlugin(std::make_unique("f")); + reg.registerPlugin( + std::make_unique("g", QStringList{"e", "f"})); + TestContext ctx; + REQUIRE(reg.setupAll(ctx).has_value()); + REQUIRE(ctx.log == QStringList{"a", "f", "b", "c", "d", "e", "g"}); +} + +TEST_CASE("missing dependency is reported before anything is set up") { + PluginRegistry reg; + reg.registerPlugin( + std::make_unique("a", QStringList{"nonexistent"})); + TestContext ctx; + const auto result = reg.setupAll(ctx); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("depends on 'nonexistent'")); + REQUIRE(ctx.log.isEmpty()); +} + +TEST_CASE("extra dependencies satisfy missing plugins") { + PluginRegistry reg; + reg.registerPlugin( + std::make_unique("a", QStringList{"external_dep"})); + TestContext ctx; + REQUIRE(reg.setupAll(ctx, QSet{"external_dep"}).has_value()); + REQUIRE(ctx.log == QStringList{"a"}); +} + +TEST_CASE("cyclic dependencies are reported as an error") { + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a", QStringList{"b"})); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + TestContext ctx; + const auto result = reg.setupAll(ctx); + REQUIRE_FALSE(result.has_value()); + REQUIRE(result.error().message.contains("Circular dependencies")); + REQUIRE(ctx.log.isEmpty()); +} + +TEST_CASE("init runs in dependency order") { + PluginRegistry reg; + reg.registerPlugin(std::make_unique("a")); + reg.registerPlugin(std::make_unique("b", QStringList{"a"})); + reg.registerPlugin(std::make_unique("c", QStringList{"b"})); + TestContext ctx; + REQUIRE(runSync(reg.initAll(ctx)).has_value()); + REQUIRE(ctx.log == QStringList{"a", "b", "c"}); +} + +TEST_CASE("shutdown runs in reverse dependency order") { + PluginRegistry reg; + QStringList shutdownOrder; + reg.registerPlugin(std::make_unique( + "dependent", QStringList{"dependency"}, &shutdownOrder)); + reg.registerPlugin(std::make_unique( + "dependency", QStringList{}, &shutdownOrder)); + REQUIRE(runSync(reg.shutdownAll()).has_value()); + REQUIRE(shutdownOrder == QStringList{"dependent", "dependency"}); +} diff --git a/tests/core/test_plugin_config.cpp b/tests/core/test_plugin_config.cpp new file mode 100644 index 0000000..dfe951e --- /dev/null +++ b/tests/core/test_plugin_config.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/plugin_config.hpp" +#include "test_support.hpp" + +namespace plugin_config_test { + +using namespace px::modrim; +using test_support::readBytes; +using test_support::writeBytes; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct DemoSettings { + bool show_badges = false; + int level = 0; + + bool operator==(const DemoSettings&) const = default; +}; +// NOLINTEND(readability-identifier-naming) + +} // namespace plugin_config_test + +using namespace plugin_config_test; + +TEST_CASE( + "plugin config seeds from legacy config.json keys and persists changes") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes(dir.filePath(QStringLiteral("config.json")), + R"({"old_badges":true,"paths":{}})")); + const QString settingsPath = + dir.filePath(QStringLiteral("plugins/demo.json")); + + ConfigService service(dir.path()); + PluginConfig settings( + &service, QStringLiteral("demo"), + QHash{ + {QStringLiteral("old_badges"), QStringLiteral("show_badges")}}); + REQUIRE(settings.value().show_badges); + REQUIRE(QFileInfo::exists(settingsPath)); + + int emitted = 0; + DemoSettings last; + settings.changed.connect([&](const DemoSettings& value) { + ++emitted; + last = value; + }); + settings.update(DemoSettings{.show_badges = false, .level = 2}); + REQUIRE(emitted == 1); + REQUIRE(last == DemoSettings{.show_badges = false, .level = 2}); + + settings.update(DemoSettings{.show_badges = false, .level = 2}); + REQUIRE(emitted == 1); + + PluginConfig reloaded(&service, QStringLiteral("demo")); + REQUIRE(reloaded.value() == DemoSettings{.show_badges = false, .level = 2}); +} + +TEST_CASE("plugin config without a service keeps settings in memory") { + PluginConfig settings(nullptr, QStringLiteral("memory")); + REQUIRE(settings.value() == DemoSettings{}); + settings.update(DemoSettings{.show_badges = true, .level = 0}); + REQUIRE(settings.value().show_badges); +} diff --git a/tests/core/test_support.hpp b/tests/core/test_support.hpp new file mode 100644 index 0000000..5d24739 --- /dev/null +++ b/tests/core/test_support.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace test_support { + +// Qt resolves database driver plugins through the application instance, so +// tests that open databases create one for their scope (one at a time, as +// test_version does). +class TestApplication { + public: + TestApplication() : m_app(m_argc, nullptr) {} + + private: + int m_argc = 0; + QCoreApplication m_app; +}; + +// Drives a coroutine whose awaited work finishes without suspending, which +// holds for every coroutine these tests start. The REQUIRE fails if it +// suspended instead. +template +QCoro::Task awaitInto(QCoro::Task task, std::optional& out) { + out.emplace(co_await std::move(task)); +} + +template +T runSync(QCoro::Task task) { + std::optional out; + [[maybe_unused]] auto pending = awaitInto(std::move(task), out); + REQUIRE(out.has_value()); + return std::move(*out); +} + +inline bool writeBytes(const QString& path, std::string_view bytes) { + QFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + return false; + } + const auto size = static_cast(bytes.size()); + return file.write(bytes.data(), size) == size; +} + +inline std::string readBytes(const QString& path) { + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly)); + const QByteArray data = file.readAll(); + return std::string(data.constData(), static_cast(data.size())); +} + +} // namespace test_support diff --git a/tests/core/test_utils.cpp b/tests/core/test_utils.cpp new file mode 100644 index 0000000..146997e --- /dev/null +++ b/tests/core/test_utils.cpp @@ -0,0 +1,69 @@ +#include +#include +#include + +#include + +#include "core/utils.hpp" + +namespace { + +void writeFile(const QString& path, const QByteArray& content) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write(content); +} + +} // namespace + +TEST_CASE("findAboutXml locates About/About.xml") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QDir mod(root.path()); + REQUIRE(mod.mkpath(QStringLiteral("About"))); + writeFile(mod.filePath(QStringLiteral("About/About.xml")), ""); + + const auto found = px::modrim::findAboutXml(root.path()); + REQUIRE(found.has_value()); + CHECK(found->endsWith(QStringLiteral("About/About.xml"))); +} + +TEST_CASE( + "findAboutXml matches the About folder and file name case-insensitively") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QDir mod(root.path()); + REQUIRE(mod.mkpath(QStringLiteral("ABOUT"))); + writeFile(mod.filePath(QStringLiteral("ABOUT/about.XML")), ""); + + const auto found = px::modrim::findAboutXml(root.path()); + REQUIRE(found.has_value()); + // Case-insensitive filesystems (macOS, Windows) resolve the canonical + // About/About.xml candidate first and keep its spelling. + CHECK( + found->endsWith(QStringLiteral("ABOUT/about.XML"), Qt::CaseInsensitive)); +} + +TEST_CASE("findAboutXml returns nullopt when About has no About.xml") { + QTemporaryDir root; + REQUIRE(root.isValid()); + REQUIRE(QDir(root.path()).mkpath(QStringLiteral("About"))); + + CHECK_FALSE(px::modrim::findAboutXml(root.path()).has_value()); +} + +TEST_CASE("findAboutXml returns nullopt for a missing directory") { + QTemporaryDir root; + REQUIRE(root.isValid()); + + CHECK_FALSE( + px::modrim::findAboutXml(root.filePath(QStringLiteral("nonexistent"))) + .has_value()); +} + +TEST_CASE("isAsciiDigits accepts only non-empty ASCII digit strings") { + CHECK(px::modrim::isAsciiDigits(QStringLiteral("123"))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(""))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral("12a"))); + CHECK_FALSE(px::modrim::isAsciiDigits(QStringLiteral(" 12"))); +} diff --git a/tests/core/test_version.cpp b/tests/core/test_version.cpp new file mode 100644 index 0000000..bebdebd --- /dev/null +++ b/tests/core/test_version.cpp @@ -0,0 +1,22 @@ +#include + +#include + +#include + +#include "core/version.hpp" + +TEST_CASE("coroutine resumes on the Qt event loop") { + int argc = 0; + QCoreApplication app(argc, nullptr); + + QString result; + auto run = [&]() -> QCoro::Task<> { + result = co_await px::modrim::delayedVersion(5); + app.quit(); + }; + run(); + app.exec(); + + REQUIRE(result == px::modrim::appVersion()); +} diff --git a/tests/core/test_xml.cpp b/tests/core/test_xml.cpp new file mode 100644 index 0000000..df2aa4d --- /dev/null +++ b/tests/core/test_xml.cpp @@ -0,0 +1,101 @@ +#include +#include +#include + +#include + +#include "core/xml.hpp" + +using namespace px::modrim; + +namespace { + +std::string dump(const glz::json_t& value) { + const auto text = value.dump(); + REQUIRE(text.has_value()); + return *text; +} + +} // namespace + +TEST_CASE( + "elementToJson maps attributes, repeated children, and trimmed text") { + pugi::xml_document doc; + REQUIRE(doc.load_string( + R"(xy hi )")); + + CHECK(dump(elementToJson(doc.document_element())) == + R"({"root":{"@a":"1","item":["x","y"],"text":"hi"}})"); +} + +TEST_CASE( + "childText returns stripped text and treats blank elements as missing") { + pugi::xml_document doc; + REQUIRE(doc.load_string(" value ")); + const pugi::xml_node root = doc.document_element(); + + REQUIRE(childText(root, "a").has_value()); + CHECK(*childText(root, "a") == QStringLiteral("value")); + CHECK_FALSE(childText(root, "b").has_value()); + CHECK_FALSE(childText(root, "missing").has_value()); +} + +TEST_CASE("appendJsonAsXml inverts elementToJson") { + glz::json_t::object_t inner; + glz::json_t::array_t items; + glz::json_t first; + first.data = std::string("a"); + glz::json_t second; + second.data = std::string("b"); + items.push_back(first); + items.push_back(second); + glz::json_t activeMods; + activeMods.data = glz::json_t::object_t{{"li", glz::json_t{}}}; + std::get(activeMods.data)["li"].data = items; + glz::json_t version; + version.data = std::string("1.5"); + inner["version"] = version; + inner["activeMods"] = activeMods; + glz::json_t data; + data.data = glz::json_t::object_t{{"ModsConfigData", glz::json_t{}}}; + std::get(data.data)["ModsConfigData"].data = inner; + + pugi::xml_document doc; + REQUIRE(appendJsonAsXml(doc, data)); + CHECK(dump(elementToJson(doc.document_element())) == dump(data)); +} + +TEST_CASE("appendJsonAsXml rejects dictionaries without exactly one root key") { + glz::json_t data; + data.data = glz::json_t::object_t{}; + pugi::xml_document doc; + const auto result = appendJsonAsXml(doc, data); + REQUIRE_FALSE(result); + CHECK(result.error().message == + QStringLiteral("Dictionary must have exactly one root key")); +} + +TEST_CASE("loadXmlFile reports missing files") { + QTemporaryDir root; + REQUIRE(root.isValid()); + pugi::xml_document doc; + const auto result = + loadXmlFile(doc, QDir(root.path()).filePath(QStringLiteral("nope.xml"))); + REQUIRE_FALSE(result); + CHECK(result.error().message.startsWith( + QStringLiteral("XML file does not exist at"))); +} + +TEST_CASE("xmlFileToJson reads a file into the legacy dict shape") { + QTemporaryDir root; + REQUIRE(root.isValid()); + const QString path = QDir(root.path()).filePath(QStringLiteral("ModIds.xml")); + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly)); + file.write("
  • one
  • two
  • "); + file.close(); + + const auto data = xmlFileToJson(path); + REQUIRE(data.has_value()); + CHECK(dump(*data) == R"({"ModIdsToFix":{"li":["one","two"]}})"); +} diff --git a/tests/core/workshop/support.hpp b/tests/core/workshop/support.hpp new file mode 100644 index 0000000..972c847 --- /dev/null +++ b/tests/core/workshop/support.hpp @@ -0,0 +1,249 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "core/workshop/types.hpp" + +namespace workshop_test { + +// Awaits a coroutine on a local event loop, so network replies and disk work +// can complete. +template +T run(QCoro::Task task) { + return QCoro::waitFor(std::move(task)); +} + +// Payloads exactly as the catalog service sends them. +inline constexpr std::string_view kModJson = R"JSON({ + "id": "2009463077", + "kind": "mod", + "source": "steam", + "title": "Harmony", + "author": { + "id": "76561198000000000", + "name": "Author", + "profile_url": "https://steamcommunity.com/profiles/76561198000000000" + }, + "description": "[b]Untrusted[/b] ", + "description_format": "bbcode", + "preview_url": "https://images.example/harmony.jpg", + "previews": [ + {"type": "image", "url": "https://images.example/harmony.jpg"}, + {"type": "video", "url": "https://www.youtube.com/watch?v=example"} + ], + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2009463077", + "tags": ["1.6", "Libraries"], + "supported_versions": ["1.5", "1.6"], + "created_at": 1600000000, + "updated_at": 1700000000, + "file_size": "18446744073709551615", + "subscriptions": 12345, + "votes": {"up": 9, "down": 1, "positive_percent": 90}, + "dependencies": ["818773962"], + "package_id": "brrainz.harmony", + "incompatible": false +})JSON"; + +inline constexpr std::string_view kCollectionJson = R"JSON({ + "id": "picked:starter", + "kind": "collection", + "source": "picked", + "steam_id": null, + "title": "Starter pack", + "author": {"id": null, "name": "PxModRim", "profile_url": null}, + "description": "Plain text", + "description_format": "text", + "preview_url": null, + "previews": [], + "workshop_url": null, + "tags": ["Quality of Life"], + "supported_versions": ["1.6"], + "created_at": 1600000000, + "updated_at": 1700000000, + "member_ids": ["2009463077"], + "member_count": 1, + "member_previews": [], + "total_size": null, + "no_common_version": false +})JSON"; + +inline px::modrim::workshop::CatalogMod decodeMod(std::string_view json) { + px::modrim::workshop::CatalogMod mod; + REQUIRE_FALSE( + glz::read(mod, std::string(json))); + return mod; +} + +inline px::modrim::workshop::CatalogCollection decodeCollection( + std::string_view json) { + px::modrim::workshop::CatalogCollection collection; + REQUIRE_FALSE(glz::read(collection, + std::string(json))); + return collection; +} + +// The wire form of any catalog type, for building responses. +template +std::string wire(const T& value) { + auto text = glz::write(value); + REQUIRE(text.has_value()); + return *text; +} + +struct HttpRequest { + std::string method; + std::string target; + std::string body; + + QUrlQuery query() const { + return QUrlQuery(QUrl(QStringLiteral("http://localhost") + + QString::fromStdString(target))); + } + QString path() const { + return QUrl(QStringLiteral("http://localhost") + + QString::fromStdString(target)) + .path(); + } +}; + +struct HttpResponse { + int status = 200; + std::string body; +}; + +using Handler = std::function; + +// A loopback catalog: answers each request with `handler`, one request per +// connection. +class FakeCatalog { + public: + explicit FakeCatalog(Handler handler) : m_handler(std::move(handler)) { + REQUIRE(m_server.listen(QHostAddress::LocalHost)); + QObject::connect(&m_server, &QTcpServer::newConnection, &m_server, + [this] { accept(); }); + } + + QString url() const { + return QStringLiteral("http://127.0.0.1:%1").arg(m_server.serverPort()); + } + + std::vector requests; + + private: + void accept() { + QTcpSocket* socket = m_server.nextPendingConnection(); + socket->setParent(&m_server); + QObject::connect(socket, &QTcpSocket::disconnected, socket, + &QObject::deleteLater); + auto raw = std::make_shared(); + auto answered = std::make_shared(false); + QObject::connect(socket, &QTcpSocket::readyRead, socket, + [this, socket, raw, answered] { + *raw += socket->readAll(); + if (*answered || !complete(*raw)) { + return; + } + *answered = true; + HttpRequest request = parse(*raw); + requests.push_back(request); + socket->write(serialize(m_handler(request))); + socket->disconnectFromHost(); + }); + } + + // True once the headers and the Content-Length body have arrived. + static bool complete(const QByteArray& raw) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + if (headersEnd < 0) { + return false; + } + return raw.size() - (headersEnd + 4) >= contentLength(raw.left(headersEnd)); + } + + static qint64 contentLength(const QByteArray& headers) { + const QByteArray lower = headers.toLower(); + const qsizetype key = lower.indexOf("content-length:"); + if (key < 0) { + return 0; + } + qsizetype eol = lower.indexOf("\r\n", key); + if (eol < 0) { + eol = lower.size(); + } + return lower.mid(key + 15, eol - key - 15).trimmed().toLongLong(); + } + + static HttpRequest parse(const QByteArray& raw) { + const qsizetype headersEnd = raw.indexOf("\r\n\r\n"); + const QByteArray requestLine = raw.left(raw.indexOf("\r\n")); + const QList parts = requestLine.split(' '); + HttpRequest request; + request.method = parts.value(0).toStdString(); + request.target = parts.value(1).toStdString(); + request.body = raw.mid(headersEnd + 4, contentLength(raw.left(headersEnd))) + .toStdString(); + return request; + } + + static QByteArray serialize(const HttpResponse& response) { + return "HTTP/1.1 " + QByteArray::number(response.status) + " " + + reason(response.status) + + "\r\nContent-Type: application/json\r\nContent-Length: " + + QByteArray::number(static_cast(response.body.size())) + + "\r\nConnection: close\r\n\r\n" + + QByteArray::fromStdString(response.body); + } + + static QByteArray reason(int status) { + switch (status) { + case 200: + return "OK"; + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 404: + return "Not Found"; + case 500: + return "Internal Server Error"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + default: + return "Status"; + } + } + + QTcpServer m_server; + Handler m_handler; +}; + +// A URL that refuses connections: the port was bound, then released. +inline QString unreachableUrl() { + QTcpServer probe; + REQUIRE(probe.listen(QHostAddress::LocalHost)); + const quint16 port = probe.serverPort(); + probe.close(); + return QStringLiteral("http://127.0.0.1:%1").arg(port); +} + +} // namespace workshop_test diff --git a/tests/core/workshop/test_catalog.cpp b/tests/core/workshop/test_catalog.cpp new file mode 100644 index 0000000..606946c --- /dev/null +++ b/tests/core/workshop/test_catalog.cpp @@ -0,0 +1,652 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "../test_support.hpp" +#include "core/config.hpp" +#include "core/downloads/downloader.hpp" +#include "core/downloads/manager.hpp" +#include "core/downloads/types.hpp" +#include "core/file_io.hpp" +#include "core/models/metadata/structures.hpp" +#include "core/workshop/catalog.hpp" +#include "core/workshop/client.hpp" +#include "core/workshop/types.hpp" +#include "support.hpp" + +using namespace px::modrim; +using namespace px::modrim::workshop; +using test_support::TestApplication; +using test_support::writeBytes; +using workshop_test::decodeMod; +using workshop_test::FakeCatalog; +using workshop_test::HttpRequest; +using workshop_test::HttpResponse; +using workshop_test::run; +using workshop_test::wire; + +namespace catalog_test { +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct BatchWire { + std::vector items; + std::vector unavailable_ids; +}; +// NOLINTEND(readability-identifier-naming) + +struct IdsBatchPayload { + std::vector ids; +}; + +struct FakeDownloader : public Downloader { + struct DownloadGate { + std::coroutine_handle<> waiter; + + // NOLINTBEGIN(readability-identifier-naming): coroutine awaiter protocol + bool await_ready() const noexcept { return false; } + void await_suspend(std::coroutine_handle<> handle) noexcept { + waiter = handle; + } + void await_resume() const noexcept {} + // NOLINTEND(readability-identifier-naming) + + void release() { std::exchange(waiter, {}).resume(); } + }; + + FakeDownloader(DownloadManager& manager, std::map synced) + : Downloader(QStringLiteral("fake_steam"), QStringLiteral("Steam"), + manager), + synced(std::move(synced)) {} + + bool isDownloading() const override { return isBusy; } + QSet activeIds() const override { return active; } + bool accepts(const QString&) const override { return true; } + std::optional updatableId(const ListedMod& mod) const override { + return mod.publishedFileId(); + } + std::optional lastSynced(const ListedMod& mod) const override { + const auto id = mod.publishedFileId(); + if (!id) { + return std::nullopt; + } + const auto found = synced.find(*id); + return found == synced.end() ? std::nullopt + : std::optional(found->second); + } + + QCoro::Task> downloadMods(QStringList ids) override { + recordedCalls.push_back(ids); + if (gate) { + isBusy = true; + active = QSet(ids.begin(), ids.end()); + busyChanged.notify(true); + co_await *gate; + isBusy = false; + active.clear(); + busyChanged.notify(false); + } + co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + } + + void cancel() override { ++cancelCalls; } + + std::map synced; + bool isBusy = false; + QSet active; + std::vector recordedCalls; + int cancelCalls = 0; + std::optional gate; +}; + +ListedModPtr makeMod(const QString& path, + const QString& provider = QStringLiteral("local")) { + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(path).has_value()); + mod->providerId = provider; + return mod; +} + +CatalogMod modWith(std::string id, std::optional updated) { + CatalogMod mod = decodeMod(workshop_test::kModJson); + mod.id = std::move(id); + mod.updated_at = updated; + return mod; +} + +struct ContextFixture { + QTemporaryDir dir; + std::unique_ptr config; + DownloadManager downloads; + std::unique_ptr downloader; + QList mods; + + ContextFixture() { + REQUIRE(dir.isValid()); + config = std::make_unique(dir.path()); + downloader = std::make_unique( + downloads, std::map{ + {QStringLiteral("1"), 200.0}, + {QStringLiteral("2"), 50.0}, + }); + downloads.registerDownloader(*downloader); + + // Mods 1, 2, 4 are local; 3 is workshop; NotWorkshop has no numeric folder + // name. + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/1")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/2")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Workshop/3")), + QStringLiteral("workshop"))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/4")))); + mods.append(makeMod(dir.filePath(QStringLiteral("Mods/NotWorkshop")))); + } +}; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct ResolvePayload { + std::vector ids; + std::vector collection_ids; +}; +// NOLINTEND(readability-identifier-naming) + +} // namespace catalog_test + +using namespace catalog_test; + +TEST_CASE("workshop install state uses sync timestamp, not directory mtime") { + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + CHECK(catalog.installState(modWith("1", 100)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("2", 100)) == InstallState::kOutdated); + // 3 has no sync record, so it cannot be outdated. + CHECK(catalog.installState(modWith("3", 100)) == InstallState::kInstalled); + CHECK(catalog.installState(modWith("4", 100)) == InstallState::kInstalled); + // 5 is not installed at all. + CHECK(catalog.installState(modWith("5", 100)) == InstallState::kMissing); + // When updated_at is null or equal to sync, the mod is installed. + CHECK(catalog.installState(modWith("2", std::nullopt)) == + InstallState::kInstalled); + CHECK(catalog.installState(modWith("2", 50)) == InstallState::kInstalled); +} + +TEST_CASE("workshop plan keeps dependency order and incomplete flags") { + TestApplication app; + ContextFixture fixture; + + FakeCatalog server([](const HttpRequest&) -> HttpResponse { + ResolvedDownload answer; + answer.roots = {"5", "picked:starter"}; + for (const char* id : {"1", "2", "3", "4", "5"}) { + CatalogMod m = modWith(id, 100); + m.title = std::string("Title ") + id; + answer.items.emplace(id, std::move(m)); + } + answer.items.emplace("picked:starter", workshop_test::decodeCollection( + workshop_test::kCollectionJson)); + answer.mod_ids = {"1", "2", "3", "4", "5"}; + answer.unavailable_ids = {"99"}; + answer.incomplete_collection_ids = {"picked:starter"}; + answer.is_complete = false; + return {200, wire(answer)}; + }); + + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + catalog.setBaseUrl(server.url()); + + const auto plan = run( + catalog.plan({QStringLiteral("5")}, {QStringLiteral("picked:starter")})); + REQUIRE(plan.has_value()); + CHECK(plan->to_download == + QStringList{QStringLiteral("2"), QStringLiteral("5")}); + CHECK(plan->already_current == QStringList{QStringLiteral("1"), + QStringLiteral("3"), + QStringLiteral("4")}); + CHECK(plan->unavailable_ids == QStringList{QStringLiteral("99")}); + CHECK(plan->incomplete_collection_ids == + QStringList{QStringLiteral("picked:starter")}); + CHECK_FALSE(plan->is_complete); + CHECK(plan->titles.value(QStringLiteral("2")) == QStringLiteral("Title 2")); + + const auto payload = + glz::read_json(server.requests.back().body); + REQUIRE(payload.has_value()); + CHECK(payload->ids == std::vector{"5"}); + CHECK(payload->collection_ids == std::vector{"picked:starter"}); +} + +TEST_CASE("workshop installed metadata uses batch") { + TestApplication app; + ContextFixture fixture; + + std::vector requestedIds; + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const auto body = glz::read_json(request.body); + REQUIRE(body.has_value()); + requestedIds = body->ids; + BatchWire answer{ + .items = {modWith("2", 100)}, + .unavailable_ids = {"1", "3", "4"}, + }; + return {200, wire(answer)}; + }); + + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + catalog.setBaseUrl(server.url()); + + const auto installed = run(catalog.installedWithUpdates()); + REQUIRE(installed.has_value()); + CHECK(requestedIds == std::vector{"1", "2", "3", "4"}); + REQUIRE(installed->size() == 1); + CHECK(catalog.installState(installed->front()) == InstallState::kOutdated); +} + +TEST_CASE("workshop download delegates without side effects") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + DownloadPlan plan{ + .to_download = {QStringLiteral("2")}, + .already_current = {QStringLiteral("1")}, + .titles = {{QStringLiteral("2"), QStringLiteral("Mod")}}, + .is_complete = true, + }; + const auto result = run(catalog.download(plan)); + REQUIRE(result.has_value()); + CHECK(result->succeeded == QStringList{QStringLiteral("2")}); + REQUIRE(fixture.downloader->recordedCalls.size() == 1); + CHECK(fixture.downloader->recordedCalls[0] == + QStringList{QStringLiteral("2")}); +} + +TEST_CASE("workshop empty URL is disabled without creating a client") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + catalog.setBaseUrl(QString()); + CHECK_FALSE(catalog.configured()); + + const auto discover = run(catalog.discover()); + REQUIRE_FALSE(discover.has_value()); + CHECK(discover.error().message.contains(QStringLiteral("disabled"))); + + const auto installed = run(catalog.installedWithUpdates()); + REQUIRE_FALSE(installed.has_value()); + CHECK(installed.error().message.contains(QStringLiteral("disabled"))); + + const auto plan = run(catalog.plan({QStringLiteral("1")}, {})); + REQUIRE_FALSE(plan.has_value()); + CHECK(plan.error().message.contains(QStringLiteral("disabled"))); +} + +TEST_CASE("workshop configuration and installed events") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + std::vector urlChanges; + int installedInvalidated = 0; + catalog.catalogUrlChanged.connect( + [&](const QString& url) { urlChanges.push_back(url); }); + catalog.installedChanged.connect( + [&](std::monostate) { ++installedInvalidated; }); + + catalog.setBaseUrl(QStringLiteral(" https://other.example/ ")); + CHECK(catalog.settings().url == "https://other.example"); + const auto stored = fixture.config->loadExisting( + QStringLiteral("plugins/workshop_catalog.json")); + REQUIRE(stored.has_value()); + REQUIRE(stored->has_value()); + CHECK((*stored)->url == "https://other.example"); + + catalog.setBaseUrl(QString()); + CHECK_FALSE(catalog.configured()); + + // Firing download_finished and manual invalidation both emit + // installed_changed. + catalog.invalidateInstalled(); + fixture.downloads.downloadFinished.notify(DownloadResult{}); + CHECK(installedInvalidated == 2); + CHECK(urlChanges == std::vector{ + QStringLiteral("https://other.example"), QString()}); +} + +TEST_CASE("workshop cached serves remembered value before refreshing") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + std::vector sequence{"first", "second", "second"}; + std::size_t nextIdx = 0; + int calls = 0; + + auto produce = [&]() -> QCoro::Task> { + ++calls; + co_return sequence.at(nextIdx++); + }; + + auto seen = [&](double freshFor) { + std::vector out; + const auto done = run(catalog.cached( + QStringLiteral("k"), produce, + [&](const std::string& v) { out.push_back(v); }, freshFor)); + REQUIRE(done.has_value()); + return out; + }; + + CHECK(seen(300.0) == std::vector{"first"}); + CHECK(seen(300.0) == std::vector{"first"}); + CHECK(calls == 1); + CHECK(seen(0.0) == std::vector{"first", "second"}); + CHECK(calls == 2); + CHECK(seen(0.0) == std::vector{"second"}); + CHECK(calls == 3); +} + +TEST_CASE("workshop enqueue runs batches in order after the running download") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + fixture.downloader->isBusy = true; + DownloadPlan first{.to_download = {QStringLiteral("5"), QStringLiteral("6")}}; + DownloadPlan second{ + .to_download = {QStringLiteral("6"), QStringLiteral("7")}}; + catalog.enqueue(first); + catalog.enqueue(second); + + CHECK(catalog.queuedIds() == QSet{QStringLiteral("5"), + QStringLiteral("6"), + QStringLiteral("7")}); + CHECK(fixture.downloader->recordedCalls.empty()); + + // Once the downloads report idle, the queue drains both batches in order. + fixture.downloader->isBusy = false; + fixture.downloads.busyChanged.notify(false); + + CHECK(fixture.downloader->recordedCalls.size() == 2); + CHECK(fixture.downloader->recordedCalls[0] == + QStringList{QStringLiteral("5"), QStringLiteral("6")}); + CHECK(fixture.downloader->recordedCalls[1] == + QStringList{QStringLiteral("7")}); + CHECK(catalog.queuedIds().isEmpty()); +} + +TEST_CASE("workshop shutdown waits for its cancelled download to finish") { + TestApplication app; + ContextFixture fixture; + fixture.downloader->gate.emplace(); + auto catalog = std::make_unique( + fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("5")}}); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("6")}}); + REQUIRE(fixture.downloader->isBusy); + REQUIRE(fixture.downloader->gate->waiter); + + bool finished = false; + auto finishShutdown = [&]() -> QCoro::Task { + co_await catalog->shutdown(); + finished = true; + }; + auto shutdown = finishShutdown(); + CHECK_FALSE(finished); + CHECK(fixture.downloader->cancelCalls == 1); + CHECK(catalog->queuedIds().isEmpty()); + + SECTION("the catalog survives until shutdown completes") {} + SECTION("the catalog is destroyed before the download finishes") { + catalog.reset(); + } + + fixture.downloader->gate->release(); + run(std::move(shutdown)); + CHECK(finished); + CHECK_FALSE(fixture.downloader->isBusy); + CHECK(fixture.downloader->recordedCalls == + std::vector{{QStringLiteral("5")}}); + + QEventLoop loop; + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + loop.exec(); +} + +TEST_CASE("workshop destruction leaves a suspended download safe to finish") { + TestApplication app; + ContextFixture fixture; + fixture.downloader->gate.emplace(); + auto catalog = std::make_unique( + fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + int queueChanges = 0; + catalog->queueChanged.connect([&](std::monostate) { ++queueChanges; }); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("5")}}); + REQUIRE(fixture.downloader->gate->waiter); + const int changesBeforeDestruction = queueChanges; + + catalog.reset(); + fixture.downloader->gate->release(); + + QEventLoop loop; + QMetaObject::invokeMethod(&loop, &QEventLoop::quit, Qt::QueuedConnection); + loop.exec(); + CHECK_FALSE(fixture.downloader->isBusy); + CHECK(queueChanges == changesBeforeDestruction); +} + +TEST_CASE("workshop shutdown releases idle waiters without cancelling others") { + TestApplication app; + ContextFixture fixture; + fixture.downloader->isBusy = true; + auto catalog = std::make_unique( + fixture.config.get(), fixture.downloads, [&] { return fixture.mods; }); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("5")}}); + REQUIRE(fixture.downloader->recordedCalls.empty()); + + SECTION("shutdown wakes the idle runner") { + run(catalog->shutdown()); + CHECK(catalog->queuedIds().isEmpty()); + catalog->enqueue(DownloadPlan{.to_download = {QStringLiteral("6")}}); + CHECK(catalog->queuedIds().isEmpty()); + } + SECTION("destruction wakes the idle runner") {} + + catalog.reset(); + fixture.downloader->isBusy = false; + fixture.downloads.busyChanged.notify(false); + CHECK(fixture.downloader->cancelCalls == 0); + CHECK(fixture.downloader->recordedCalls.empty()); +} + +TEST_CASE( + "workshop cached values survive a restart and corrupt files are ignored") { + TestApplication app; + ContextFixture fixture; + + auto produce = []() -> QCoro::Task>> { + co_return std::vector{1, 2}; + }; + auto mustNotRun = []() -> QCoro::Task>> { + FAIL("A fresh value on disk must not hit the catalog"); + co_return std::vector{}; + }; + + // First run persists to disk. + { + WorkshopCatalog first(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector out; + const auto done = run(first.cached>( + QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, + /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } + + // Second run loads from disk without invoking produce. + { + WorkshopCatalog second(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector out; + const auto done = run(second.cached>( + QStringLiteral("k"), mustNotRun, [&](const auto& v) { out = v; }, 300.0, + /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } + + // Corrupting the file causes a miss: produce is invoked again. + const QString cacheDir = + fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : + QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); + } + { + WorkshopCatalog third(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector out; + const auto done = run(third.cached>( + QStringLiteral("k"), produce, [&](const auto& v) { out = v; }, 300.0, + /*persist=*/true)); + REQUIRE(done.has_value()); + CHECK(out == std::vector{1, 2}); + } +} + +TEST_CASE( + "workshop equal cache refresh keeps the displayed mods and refreshes age") { + TestApplication app; + ContextFixture fixture; + WorkshopCatalog catalog(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + + int calls = 0; + auto produce = [&]() -> QCoro::Task>> { + ++calls; + co_return std::vector{modWith("1", 100)}; + }; + + std::vector> initial; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, + [&](const auto& v) { initial.push_back(v); }, 300.0)) + .has_value()); + CHECK(initial.size() == 1); + CHECK(calls == 1); + + // Refresh with fresh_for = 0: produce runs, but because the value is equal, + // show is called only once. + std::vector> refreshed; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, + [&](const auto& v) { refreshed.push_back(v); }, 0.0)) + .has_value()); + CHECK(refreshed.size() == 1); + CHECK(refreshed[0] == initial[0]); + CHECK(calls == 2); + + // Immediate repeat: the refreshed timestamp is fresh, so produce does not + // run. + std::vector> current; + REQUIRE(run(catalog.cached>( + QStringLiteral("installed"), produce, + [&](const auto& v) { current.push_back(v); }, 300.0)) + .has_value()); + CHECK(current.size() == 1); + CHECK(current[0] == initial[0]); + CHECK(calls == 2); +} + +TEST_CASE("workshop disk cache is reused in memory after first load") { + TestApplication app; + ContextFixture fixture; + + auto produce = []() -> QCoro::Task>> { + co_return std::vector{modWith("1", 100)}; + }; + auto mustNotRun = + []() -> QCoro::Task>> { + FAIL("A fresh cached response must not hit the catalog"); + co_return std::vector{}; + }; + + { + WorkshopCatalog first(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + REQUIRE( + run(first.cached>( + QStringLiteral("installed"), produce, [](const auto&) {}, 300.0, + /*persist=*/true)) + .has_value()); + } + + WorkshopCatalog second(fixture.config.get(), fixture.downloads, + [&] { return fixture.mods; }); + std::vector loaded; + REQUIRE(run(second.cached>( + QStringLiteral("installed"), mustNotRun, + [&](const auto& v) { loaded = v; }, 300.0, /*persist=*/true)) + .has_value()); + REQUIRE(loaded.size() == 1); + + // Corrupt the disk file now: second catalog must still serve its in-memory + // copy. + const QString cacheDir = + fixture.dir.filePath(QStringLiteral("workshop-cache")); + for (const QFileInfo& file : + QDir(cacheDir).entryInfoList({QStringLiteral("*.json")}, QDir::Files)) { + REQUIRE(writeBytes(file.absoluteFilePath(), "{not json")); + } + + std::vector fromMemory; + REQUIRE( + run(second.cached>( + QStringLiteral("installed"), mustNotRun, + [&](const auto& v) { fromMemory = v; }, 300.0, /*persist=*/true)) + .has_value()); + CHECK(fromMemory == loaded); +} + +TEST_CASE("workshop catalog URL moves out of the old shared config") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + REQUIRE(writeBytes( + dir.filePath(QStringLiteral("config.json")), + R"({"schema_version":1,"workshop_catalog_url":"http://localhost:8787"})")); + + ConfigService service(dir.path()); + DownloadManager downloads; + WorkshopCatalog catalog(&service, downloads, + [] { return QList{}; }); + CHECK(catalog.settings().url == "http://localhost:8787"); + + const auto stored = service.loadExisting( + QStringLiteral("plugins/workshop_catalog.json")); + REQUIRE(stored.has_value()); + REQUIRE(stored->has_value()); + CHECK((*stored)->url == "http://localhost:8787"); +} diff --git a/tests/core/workshop/test_client.cpp b/tests/core/workshop/test_client.cpp new file mode 100644 index 0000000..8660da8 --- /dev/null +++ b/tests/core/workshop/test_client.cpp @@ -0,0 +1,467 @@ +#include +#include +#include +#include +#include + +#include +#include + +#include "../test_support.hpp" +#include "core/workshop/client.hpp" +#include "core/workshop/types.hpp" +#include "support.hpp" + +using namespace px::modrim::workshop; +using test_support::TestApplication; +using workshop_test::decodeCollection; +using workshop_test::decodeMod; +using workshop_test::FakeCatalog; +using workshop_test::HttpRequest; +using workshop_test::HttpResponse; +using workshop_test::run; +using workshop_test::unreachableUrl; +using workshop_test::wire; + +namespace client_test { + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct ResolveRequest { + std::vector ids; + std::vector collection_ids; +}; +// NOLINTEND(readability-identifier-naming) + +struct IdsRequest { + std::vector ids; +}; + +// The `{"items":[...],"total":N,"next_cursor":...}` page shape. +std::string page(const std::string& items, int total, + const std::string& cursorJson) { + return R"({"items":[)" + items + R"(],"total":)" + std::to_string(total) + + R"(,"next_cursor":)" + cursorJson + "}"; +} + +std::string errorBody(const std::string& message) { + return R"({"error":{"message":")" + message + R"("}})"; +} + +QString queryValue(const HttpRequest& request, const char* key) { + return request.query().queryItemValue(QString::fromLatin1(key)); +} + +CatalogMod withId(std::string id) { + CatalogMod mod = decodeMod(workshop_test::kModJson); + mod.id = std::move(id); + return mod; +} + +CatalogCollection steamCollection(std::string id, std::string steamId, + std::vector members) { + CatalogCollection collection = + decodeCollection(workshop_test::kCollectionJson); + collection.id = std::move(id); + collection.source = CollectionSource::kSteam; + collection.steam_id = std::move(steamId); + collection.workshop_url = + "https://steamcommunity.com/sharedfiles/filedetails/?id=" + + *collection.steam_id; + collection.member_count = static_cast(members.size()); + collection.member_ids = std::move(members); + return collection; +} + +} // namespace client_test + +using namespace client_test; + +TEST_CASE("workshop client decodes every endpoint and sends the query") { + TestApplication app; + const std::string mod = std::string(workshop_test::kModJson); + const std::string collection = std::string(workshop_test::kCollectionJson); + const std::string nested = + wire(steamCollection("steam:456", "456", {"2009463077"})); + const std::string steam = wire( + steamCollection("steam:123", "123", {"2009463077", "steam:456", "99"})); + + const std::string detailPicked = + R"({"collection":)" + collection + R"(,"members":[)" + mod + + R"(],"unavailable_ids":["99"],"is_complete":false})"; + const std::string detailSteam = + R"({"collection":)" + steam + R"(,"members":[)" + mod + "," + nested + + R"(],"unavailable_ids":["99"],"is_complete":false})"; + const std::string resolution = + R"({"roots":["picked:starter"],"items":{"2009463077":)" + mod + + R"(,"picked:starter":)" + collection + + R"(},"mod_ids":["2009463077"],"unavailable_ids":["99"],)" + R"("incomplete_collection_ids":["picked:starter"],"is_complete":false})"; + + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const std::string path = request.path().toStdString(); + if (path == "/catalog/discover") { + return {200, R"({"mods":)" + page(mod, 1, R"("next-page")") + + R"(,"collections":)" + page(collection, 1, "null") + + "}"}; + } + if (path == "/catalog/mods") { + return {200, page(mod, 1, R"("next-page")")}; + } + if (path == "/catalog/collections") { + return {200, page(collection, 1, "null")}; + } + if (path == "/catalog/mods/2009463077") { + return {200, mod}; + } + if (path == "/catalog/collections/picked/starter") { + return {200, detailPicked}; + } + if (path == "/catalog/collections/steam/123") { + return {200, detailSteam}; + } + if (path == "/catalog/resolve") { + return {200, resolution}; + } + return {404, errorBody("unknown")}; + }); + + CatalogClient client(server.url()); + const auto discover = run(client.discover()); + REQUIRE(discover.has_value()); + CHECK(discover->mods.items[0].file_size == + std::string("18446744073709551615")); + CHECK(discover->mods.items[0].votes->positive_percent == 90.0); + CHECK(discover->mods.items[0].previews[1].type == PreviewType::kVideo); + CHECK(discover->collections.items[0].description_format == + CollectionDescriptionFormat::kText); + + CatalogQuery query; + query.query = QStringLiteral("quality"); + query.tag = QStringLiteral("Libraries"); + query.version = QStringLiteral("1.6"); + query.sort = QStringLiteral("updated"); + query.limit = 50; + query.cursor = QStringLiteral("next"); + query.source = QStringLiteral("picked"); + const auto mods = run(client.mods(query)); + REQUIRE(mods.has_value()); + CHECK(mods->next_cursor == std::optional("next-page")); + const HttpRequest& sent = server.requests.back(); + CHECK(queryValue(sent, "q") == QStringLiteral("quality")); + CHECK(queryValue(sent, "tag") == QStringLiteral("Libraries")); + CHECK(queryValue(sent, "version") == QStringLiteral("1.6")); + CHECK(queryValue(sent, "sort") == QStringLiteral("updated")); + CHECK(queryValue(sent, "limit") == QStringLiteral("50")); + CHECK(queryValue(sent, "cursor") == QStringLiteral("next")); + CHECK(queryValue(sent, "source") == QStringLiteral("picked")); + CHECK(sent.query().queryItems().size() == 7); + + const auto collections = run(client.collections(query)); + REQUIRE(collections.has_value()); + CHECK(collections->items[0].id == "picked:starter"); + + const auto single = run(client.mod("2009463077")); + REQUIRE(single.has_value()); + REQUIRE(single->has_value()); + CHECK((*single)->description == decodeMod(mod).description); + CHECK((*single)->kind == ModKind::kMod); + + for (const char* id : {"picked:starter", "steam:123"}) { + const auto detail = run(client.collection(id)); + REQUIRE(detail.has_value()); + REQUIRE(detail->has_value()); + const CollectionDetail& value = **detail; + const bool isSteam = std::string(id) == "steam:123"; + REQUIRE(value.members.size() == (isSteam ? 2 : 1)); + CHECK(std::holds_alternative(value.members[0])); + CHECK(value.unavailable_ids == std::vector{"99"}); + CHECK_FALSE(value.is_complete); + if (isSteam) { + CHECK(std::holds_alternative(value.members[1])); + } + } + + const auto resolved = run(client.resolve({}, {"picked:starter"})); + REQUIRE(resolved.has_value()); + CHECK(resolved->roots == std::vector{"picked:starter"}); + CHECK(resolved->mod_ids == std::vector{"2009463077"}); + CHECK(std::holds_alternative( + resolved->items.at("picked:starter"))); + CHECK(resolved->incomplete_collection_ids == + std::vector{"picked:starter"}); + CHECK_FALSE(resolved->is_complete); + const auto body = glz::read_json(server.requests.back().body); + REQUIRE(body.has_value()); + CHECK(body->ids.empty()); + CHECK(body->collection_ids == std::vector{"picked:starter"}); +} + +TEST_CASE( + "workshop client reports error statuses as errors, never as empty " + "results") { + TestApplication app; + for (int status : {400, 401, 500, 502, 503}) { + for (bool jsonBody : {true, false}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { + return jsonBody ? HttpResponse{status, errorBody("Catalog unavailable")} + : HttpResponse{status, "Upstream failure"}; + }); + CatalogClient client(server.url()); + const auto result = run(client.mod("123")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().status == status); + if (jsonBody) { + CHECK(result.error().message == QStringLiteral("Catalog unavailable")); + } else { + CHECK(result.error().message.contains( + QStringLiteral("HTTP %1").arg(status))); + CHECK_FALSE(result.error().message.contains(QStringLiteral(""))); + } + } + } +} + +TEST_CASE("workshop optional details return nothing only for a 404") { + TestApplication app; + for (bool jsonBody : {true, false}) { + FakeCatalog server([&](const HttpRequest&) -> HttpResponse { + return jsonBody ? HttpResponse{404, errorBody("Item unavailable")} + : HttpResponse{404, "Not found"}; + }); + CatalogClient client(server.url()); + const auto mod = run(client.mod("123")); + REQUIRE(mod.has_value()); + CHECK_FALSE(mod->has_value()); + const auto collection = run(client.collection("steam:123")); + REQUIRE(collection.has_value()); + CHECK_FALSE(collection->has_value()); + } +} + +TEST_CASE( + "workshop optional details keep server, network and decoding failures") { + TestApplication app; + FakeCatalog failing( + [](const HttpRequest&) -> HttpResponse { return {500, "Server error"}; }); + CatalogClient failingClient(failing.url()); + const auto serverError = run(failingClient.mod("123")); + REQUIRE_FALSE(serverError.has_value()); + CHECK(serverError.error().status == 500); + const auto collectionError = run(failingClient.collection("steam:123")); + REQUIRE_FALSE(collectionError.has_value()); + CHECK(collectionError.error().status == 500); + + CatalogClient offline(unreachableUrl()); + const auto network = run(offline.mod("123")); + REQUIRE_FALSE(network.has_value()); + CHECK_FALSE(network.error().status.has_value()); + + FakeCatalog garbage( + [](const HttpRequest&) -> HttpResponse { return {200, "not-json"}; }); + CatalogClient garbageClient(garbage.url()); + const auto decoding = run(garbageClient.collection("steam:123")); + REQUIRE_FALSE(decoding.has_value()); + CHECK(decoding.error().status == 200); +} + +TEST_CASE("workshop non-optional endpoints keep 404 errors") { + TestApplication app; + FakeCatalog server( + [](const HttpRequest&) -> HttpResponse { return {404, ""}; }); + CatalogClient client(server.url()); + CatalogQuery query; + + const auto mods = run(client.mods(query)); + REQUIRE_FALSE(mods.has_value()); + CHECK(mods.error().status == 404); + const auto collections = run(client.collections(query)); + REQUIRE_FALSE(collections.has_value()); + CHECK(collections.error().status == 404); + const auto batch = run(client.modsBatch({"123"})); + REQUIRE_FALSE(batch.has_value()); + CHECK(batch.error().status == 404); + const auto resolved = run(client.resolve({"123"}, {})); + REQUIRE_FALSE(resolved.has_value()); + CHECK(resolved.error().status == 404); +} + +TEST_CASE("workshop client rejects invalid collection ids before sending") { + TestApplication app; + FakeCatalog server( + [](const HttpRequest&) -> HttpResponse { return {200, "{}"}; }); + CatalogClient client(server.url()); + for (const char* id : {"steam", "steam:", ":123", "other:123"}) { + const auto result = run(client.collection(id)); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == + QStringLiteral("Invalid Workshop collection ID.")); + } + CHECK(server.requests.empty()); +} + +TEST_CASE( + "workshop client treats responses that violate the wire contract as " + "invalid") { + TestApplication app; + const std::string validMod(workshop_test::kModJson); + std::string numericId = validMod; + numericId.replace(numericId.find("\"2009463077\""), 12, "123"); + std::string badKind = validMod; + badKind.replace(badKind.find("\"mod\""), 5, "\"collection\""); + std::string missingTitle = validMod; + missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); + + for (const std::string& body : + {numericId, badKind, missingTitle, std::string("not-json")}) { + FakeCatalog server( + [&](const HttpRequest&) -> HttpResponse { return {200, body}; }); + CatalogClient client(server.url()); + const auto result = run(client.mod("123")); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message == + QStringLiteral("The Workshop catalog returned an invalid response.")); + CHECK(result.error().status == 200); + } +} + +TEST_CASE("workshop batch requests split ids into chunks of 100") { + TestApplication app; + FakeCatalog server([](const HttpRequest& request) -> HttpResponse { + const auto ids = glz::read_json(request.body); + REQUIRE(ids.has_value()); + std::string items; + std::vector unavailable; + for (const std::string& id : ids->ids) { + if (id == "105") { + unavailable.push_back(id); + continue; + } + items += (items.empty() ? "" : ",") + wire(withId(id)); + } + std::string unavailableJson = "["; + for (const std::string& id : unavailable) { + unavailableJson += "\"" + id + "\""; + } + return {200, R"({"items":[)" + items + R"(],"unavailable_ids":)" + + unavailableJson + "]}"}; + }); + + CatalogClient client(server.url()); + std::vector ids; + for (int id = 1; id <= 205; ++id) { + ids.push_back(std::to_string(id)); + } + QStringList qids; + for (const std::string& id : ids) { + qids.append(QString::fromStdString(id)); + } + const auto result = run(client.modsBatch(qids)); + REQUIRE(result.has_value()); + + REQUIRE(server.requests.size() == 3); + CHECK(glz::read_json(server.requests[0].body)->ids.size() == 100); + CHECK(glz::read_json(server.requests[1].body)->ids.size() == 100); + CHECK(glz::read_json(server.requests[2].body)->ids.size() == 5); + CHECK(result->items.size() == 204); + CHECK(result->items.front().id == "1"); + CHECK(result->unavailableIds == std::vector{"105"}); + + const auto empty = run(client.modsBatch({})); + REQUIRE(empty.has_value()); + CHECK(empty->items.empty()); + CHECK(empty->unavailableIds.empty()); + CHECK(server.requests.size() == 3); +} + +TEST_CASE("workshop transport failures are catalog errors without a status") { + TestApplication app; + CatalogClient client(unreachableUrl()); + const auto result = run(client.discover()); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.startsWith( + QStringLiteral("Cannot reach the Workshop catalog"))); + CHECK_FALSE(result.error().status.has_value()); +} + +TEST_CASE("workshop disabled client sends nothing") { + TestApplication app; + CatalogClient client(QStringLiteral("")); + const auto result = run(client.discover()); + REQUIRE_FALSE(result.has_value()); + CHECK(result.error().message.contains(QStringLiteral("disabled"))); + CHECK_FALSE(result.error().status.has_value()); +} + +TEST_CASE("workshop resolve splits requests and merges their answers") { + TestApplication app; + std::vector sent; + FakeCatalog server([&](const HttpRequest& request) -> HttpResponse { + const auto body = glz::read_json(request.body); + REQUIRE(body.has_value()); + sent.push_back(*body); + const bool second = sent.size() == 2; + + // Every answer repeats the dependency 999, which must be reported once. + std::vector modIds{"999"}; + modIds.insert(modIds.end(), body->ids.begin(), body->ids.end()); + std::string items = "{"; + for (const std::string& id : modIds) { + items += "\"" + id + "\":" + wire(withId(id)); + items += id == modIds.back() ? "" : ","; + } + items += "}"; + std::string modsJson = "["; + for (const std::string& id : modIds) { + modsJson += "\"" + id + "\","; + } + modsJson.back() = ']'; + std::string rootsJson = "["; + for (const std::string& id : body->ids) { + rootsJson += "\"" + id + "\","; + } + for (const std::string& id : body->collection_ids) { + rootsJson += "\"" + id + "\","; + } + rootsJson.back() = ']'; + + return {200, R"({"roots":)" + rootsJson + R"(,"items":)" + items + + R"(,"mod_ids":)" + modsJson + R"(,"unavailable_ids":)" + + (second ? R"(["404"])" : "[]") + + R"(,"incomplete_collection_ids":)" + + (second ? R"(["picked:pack-10"])" : "[]") + + R"(,"is_complete":)" + (second ? "false" : "true") + "}"}; + }); + + std::vector ids; + for (int id = 1; id <= 103; ++id) { + ids.push_back(std::to_string(id)); + } + QStringList qids; + for (const std::string& id : ids) { + qids.append(QString::fromStdString(id)); + } + QStringList collections; + for (int index = 0; index < 11; ++index) { + collections.append(QStringLiteral("picked:pack-%1").arg(index)); + } + + CatalogClient client(server.url()); + const auto resolved = run(client.resolve(qids, collections)); + REQUIRE(resolved.has_value()); + + REQUIRE(sent.size() == 3); + CHECK(sent[0].ids.size() == 50); + CHECK(sent[1].ids.size() == 50); + CHECK(sent[2].ids.size() == 3); + CHECK(sent[0].collection_ids.size() == 10); + CHECK(sent[1].collection_ids.size() == 1); + CHECK(sent[2].collection_ids.empty()); + + CHECK(resolved->roots.size() == 114); + CHECK(resolved->mod_ids.size() == 104); + CHECK(resolved->mod_ids.front() == "999"); + CHECK(resolved->unavailable_ids == std::vector{"404"}); + CHECK(resolved->incomplete_collection_ids == + std::vector{"picked:pack-10"}); + CHECK_FALSE(resolved->is_complete); +} diff --git a/tests/core/workshop/test_favourites.cpp b/tests/core/workshop/test_favourites.cpp new file mode 100644 index 0000000..204fd87 --- /dev/null +++ b/tests/core/workshop/test_favourites.cpp @@ -0,0 +1,79 @@ +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/workshop/favourites.hpp" +#include "core/workshop/types.hpp" +#include "support.hpp" + +using namespace px::modrim; +using namespace px::modrim::workshop; +using workshop_test::decodeCollection; + +namespace { + +CatalogCollection makeCollection(std::string id, std::string title = "Pack") { + CatalogCollection collection = + decodeCollection(workshop_test::kCollectionJson); + collection.id = std::move(id); + collection.title = std::move(title); + return collection; +} + +} // namespace + +TEST_CASE("workshop favourites persist newest first and survive a restart") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + + FavouriteCollections favourites(&service); + const CatalogCollection first = makeCollection("steam:1"); + const CatalogCollection second = makeCollection("steam:2"); + favourites.set(first, true); + favourites.set(second, true); + // Setting an already-starred collection again is a no-op: it does not jump to + // the front. + favourites.set(second, true); + + FavouriteCollections reloaded(&service); + REQUIRE(reloaded.items().size() == 2); + CHECK(reloaded.items()[0].id == "steam:2"); + CHECK(reloaded.items()[1].id == "steam:1"); + CHECK(reloaded.contains("steam:1")); + + reloaded.set(first, false); + FavouriteCollections afterRemoval(&service); + REQUIRE(afterRemoval.items().size() == 1); + CHECK(afterRemoval.items()[0].id == "steam:2"); + CHECK_FALSE(afterRemoval.contains("steam:1")); +} + +TEST_CASE("workshop favourites remember refreshes only starred snapshots") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + ConfigService service(dir.path()); + + FavouriteCollections favourites(&service); + favourites.set(makeCollection("steam:1", "Old title"), true); + + int saves = 0; + favourites.changed().connect([&](const FavouritesState&) { ++saves; }); + + // An equal collection does not save. + favourites.remember(makeCollection("steam:1", "Old title")); + // A collection that was never starred does not save. + favourites.remember(makeCollection("steam:2", "Not starred")); + CHECK(saves == 0); + + favourites.remember(makeCollection("steam:1", "New title")); + CHECK(saves == 1); + + FavouriteCollections reloaded(&service); + REQUIRE(reloaded.items().size() == 1); + CHECK(reloaded.items()[0].title == "New title"); + CHECK(reloaded.items()[0].id == "steam:1"); +} diff --git a/tests/core/workshop/test_types.cpp b/tests/core/workshop/test_types.cpp new file mode 100644 index 0000000..158acb7 --- /dev/null +++ b/tests/core/workshop/test_types.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "core/workshop/types.hpp" +#include "support.hpp" + +using namespace px::modrim::workshop; +using workshop_test::decodeCollection; +using workshop_test::decodeMod; +using workshop_test::wire; + +namespace { + +// Decodes `json` as a `T`, returning whether glaze accepted it. +template +bool accepts(const std::string& json) { + T value{}; + return !glz::read(value, json); +} + +} // namespace + +TEST_CASE( + "workshop mod decodes with nulls and round-trips through the wire form") { + CatalogMod mod = decodeMod(workshop_test::kModJson); + REQUIRE(mod.id == "2009463077"); + REQUIRE(mod.title == "Harmony"); + REQUIRE(mod.votes.has_value()); + REQUIRE(mod.votes->positive_percent == 90.0); + REQUIRE(mod.previews[1].type == PreviewType::kVideo); + REQUIRE(mod.file_size == std::string("18446744073709551615")); + + // Absent values stay absent instead of being invented. + const std::string nulled = R"({ + "id": "7", "kind": "mod", "source": "steam", "title": "T", + "author": {"id": "1", "name": null, "profile_url": null}, + "description": "", "description_format": "bbcode", "preview_url": null, + "previews": [], "workshop_url": "u", "tags": [], "supported_versions": [], + "created_at": null, "updated_at": null, "file_size": null, "subscriptions": null, + "votes": null, "dependencies": [], "package_id": null, "incompatible": false + })"; + CatalogMod sparse = decodeMod(nulled); + CHECK_FALSE(sparse.author.name.has_value()); + CHECK_FALSE(sparse.votes.has_value()); + CHECK_FALSE(sparse.updated_at.has_value()); + CHECK_FALSE(sparse.file_size.has_value()); + + const std::string encoded = wire(sparse); + CHECK(encoded.find(R"("kind":"mod")") != std::string::npos); + CHECK(decodeMod(encoded) == sparse); + CHECK(decodeMod(wire(mod)) == mod); +} + +TEST_CASE("workshop steam collection and empty page decode") { + CatalogCollection collection = decodeCollection(R"({ + "id": "steam:123", "kind": "collection", "source": "steam", "steam_id": "123", + "title": "Pack", "author": {"id": null, "name": "Curator", "profile_url": null}, + "description": "[b]x[/b]", "description_format": "bbcode", + "preview_url": null, "previews": [], + "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", + "tags": [], "supported_versions": ["1.6"], "created_at": null, "updated_at": 5, + "member_ids": ["2009463077", "steam:456"], "member_count": 2, + "member_previews": ["https://images.example.test/a.png"], + "total_size": "10", "no_common_version": true + })"); + CHECK(collection.member_ids == + std::vector{"2009463077", "steam:456"}); + CHECK(collection.source == CollectionSource::kSteam); + CHECK(collection.no_common_version); + CHECK(decodeCollection(wire(collection)) == collection); + + CatalogPage page{}; + REQUIRE(accepts>( + R"({"items":[],"total":0,"next_cursor":null})")); + REQUIRE_FALSE(glz::read( + page, std::string(R"({"items":[],"total":0,"next_cursor":null})"))); + CHECK(page.items.empty()); + CHECK(page.total == 0); + CHECK_FALSE(page.next_cursor.has_value()); +} + +TEST_CASE( + "workshop wire decoding rejects payloads outside the catalog contract") { + // A numeric id is not a string. + std::string numericId(workshop_test::kModJson); + numericId.replace(numericId.find("\"2009463077\""), 12, "123"); + CHECK_FALSE(accepts(numericId)); + + // A collection payload is not a mod. + CHECK_FALSE(accepts(std::string(workshop_test::kCollectionJson))); + + // A mod payload is not a collection. + CHECK_FALSE(accepts(std::string(workshop_test::kModJson))); + + // A missing required key fails. + std::string missingTitle(workshop_test::kModJson); + missingTitle.replace(missingTitle.find("\"title\": \"Harmony\","), 21, ""); + CHECK_FALSE(accepts(missingTitle)); + + // A preview type outside image|video fails. + std::string badPreview(workshop_test::kModJson); + badPreview.replace(badPreview.find("\"image\""), 7, "\"audio\""); + CHECK_FALSE(accepts(badPreview)); + + CHECK_FALSE(accepts("not-json")); +} + +TEST_CASE("workshop wire decoding ignores keys the contract does not name") { + std::string extra(workshop_test::kModJson); + extra.insert(1, R"("unexpected": {"nested": true},)"); + CHECK(accepts(extra)); +} + +TEST_CASE("workshop catalog query has the documented defaults") { + CatalogQuery query; + CHECK(query.query.isEmpty()); + CHECK(query.sort == QStringLiteral("popular")); + CHECK(query.limit == 24); + CHECK(query.source == QStringLiteral("all")); + CHECK_FALSE(query.tag.has_value()); + CHECK_FALSE(query.version.has_value()); + CHECK_FALSE(query.cursor.has_value()); +} diff --git a/tests/test_metadata/data/local_mods/valid_mod/About/About.xml b/tests/data/local_mods/valid_mod/About/About.xml similarity index 100% rename from tests/test_metadata/data/local_mods/valid_mod/About/About.xml rename to tests/data/local_mods/valid_mod/About/About.xml diff --git a/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml b/tests/data/rimworld_game/Data/ludeon.rimworld/About/About.xml similarity index 100% rename from tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml rename to tests/data/rimworld_game/Data/ludeon.rimworld/About/About.xml diff --git a/tests/test_metadata/data/valid_mod/About/About.xml b/tests/data/valid_mod/About/About.xml similarity index 100% rename from tests/test_metadata/data/valid_mod/About/About.xml rename to tests/data/valid_mod/About/About.xml diff --git a/tests/test_checker/__init__.py b/tests/test_checker/__init__.py deleted file mode 100644 index 9d48db4..0000000 --- a/tests/test_checker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/tests/test_checker/test_checker.py b/tests/test_checker/test_checker.py deleted file mode 100644 index 2b74fe7..0000000 --- a/tests/test_checker/test_checker.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations - -import random - -from pxmodrim.core.checker.checker import ModChecker -from pxmodrim.core.checker.graph import EdgeType -from pxmodrim.core.checker.issues import ( - CycleIssueChecker, - DependencyIssueChecker, - IncompatibilityIssueChecker, - LoadOrderIssueChecker, - ModIssueChecker, -) -from pxmodrim.core.checker.models import ModDiagnostics, PackageId -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.sort.config import SortSettings - - -def _make_mod(package_id: str, name: str | None = None) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = name or package_id - m._mod_path = None - return m - - -def _settings() -> SortSettings: - return SortSettings( - use_community_rules=False, - use_alternative_package_ids=True, - check_missing_dependencies=True, - ) - - -def _as_listed(mods: list[AboutXmlMod]) -> dict[str, ListedMod]: - return {str(i): m for i, m in enumerate(mods)} - - -def _uuid_for(mods: dict[str, ListedMod], name: str) -> str: - for u, m in mods.items(): - if m.name == name: - return u - raise ValueError(f"Mod {name} not found") - - -class TestModChecker: - def test_basic_rebuild(self): - checkers = [ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - LoadOrderIssueChecker(), - CycleIssueChecker(), - ] - a = _make_mod("mod.a") - b = _make_mod("mod.b") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.c") - a.about_rules.dependencies = {CaseInsensitiveStr("mod.c"): dep} - mods = _as_listed([a, b]) - active_uuids = [u for u, m in mods.items() if m.name in ("mod.a", "mod.b")] - - collected_diags: dict[str, ModDiagnostics] = {} - - def on_change(diags: dict[str, ModDiagnostics]) -> None: - collected_diags.update(diags) - - checker = ModChecker(checkers, _settings(), on_diagnostics_changed=on_change) - checker.rebuild(mods, active_uuids) - - # 'a' has a missing dependency on 'mod.c' - a_uuid = _uuid_for(mods, "mod.a") - diag_a = checker.diagnostics_for(a_uuid) - assert diag_a is not None - assert diag_a.has_errors - assert len(diag_a.errors) == 1 - assert diag_a.errors[0].category == "missing_dependency" - - # 'b' has no issues - b_uuid = _uuid_for(mods, "mod.b") - diag_b = checker.diagnostics_for(b_uuid) - assert diag_b is not None - assert not diag_b.has_errors - assert not diag_b.has_warnings - - def test_toggle_mod_rebuilds_diagnostics_and_constraints(self): - checkers = [ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - ] - a = _make_mod("mod.a") - b = _make_mod("mod.b") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {CaseInsensitiveStr("mod.b"): dep} - b.about_rules.load_after = CaseInsensitiveSet(["mod.a"]) - mods = _as_listed([a, b]) - a_uuid = _uuid_for(mods, "mod.a") - b_uuid = _uuid_for(mods, "mod.b") - - checker = ModChecker(checkers, _settings()) - checker.rebuild(mods, [a_uuid, b_uuid]) - - checker.toggle_mod(mods[b_uuid], False, [a_uuid]) - - diag = checker.diagnostics_for(a_uuid) - assert diag is not None and diag.has_errors - assert diag.errors[0].category == "missing_dependency" - - checker.toggle_mod(mods[b_uuid], True, [a_uuid, b_uuid]) - - diag = checker.diagnostics_for(a_uuid) - assert diag is not None and not diag.has_errors - dependency_sources = { - edge.source - for edge in checker.graph.incoming_of_type( - PackageId("mod.b"), EdgeType.DEPENDENCY - ) - } - assert dependency_sources == {PackageId("mod.a")} - load_after_targets = { - edge.target - for edge in checker.graph.edges_of_type( - PackageId("mod.b"), EdgeType.LOAD_AFTER - ) - } - assert load_after_targets == {PackageId("mod.a")} - - def test_move_mod_updates_load_order(self): - checkers: list[ModIssueChecker] = [ - LoadOrderIssueChecker(), - ] - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - # Order: b first, then a -> satisfies load_after - mods = _as_listed([b, a]) - active_uuids = list(mods) - - checker = ModChecker(checkers, _settings()) - checker.rebuild(mods, active_uuids) - - a_uuid = _uuid_for(mods, "mod.a") - - # Initially b is before a, so no load_after violation for a - diag = checker.diagnostics_for(a_uuid) - assert diag is None or not diag.has_warnings - - # Move a before b - checker.move_mod(a_uuid, 0, 1) - - # Now a is before b, violating load_after - diag = checker.diagnostics_for(a_uuid) - assert diag is not None and diag.has_warnings - assert diag.warnings[0].category == "load_order" - - def test_reorder_matches_full_rebuild(self): - # reorder() reuses cached results for mods whose indices and load-order - # targets did not move; it must still equal a from-scratch rebuild. - def make_checkers() -> list[ModIssueChecker]: - return [ - DependencyIssueChecker(), - IncompatibilityIssueChecker(), - LoadOrderIssueChecker(), - CycleIssueChecker(), - ] - - rng = random.Random(0) - pids = [f"mod.{i}" for i in range(12)] - mods_list = [_make_mod(pid) for pid in pids] - for mod in mods_list: - others = [p for p in pids if p != str(mod.package_id)] - mod.about_rules.load_after = CaseInsensitiveSet(rng.sample(others, 2)) - mod.about_rules.load_before = CaseInsensitiveSet(rng.sample(others, 1)) - mods = _as_listed(mods_list) - order = list(mods) - - incremental = ModChecker(make_checkers(), _settings()) - incremental.rebuild(mods, order) - for _ in range(30): - if rng.random() < 0.5: - i, j = rng.sample(range(len(order)), 2) - order[i], order[j] = order[j], order[i] - else: - order.insert( - rng.randrange(len(order)), order.pop(rng.randrange(len(order))) - ) - incremental.reorder(order) - - full = ModChecker(make_checkers(), _settings()) - full.rebuild(mods, order) - for uuid in order: - got = incremental.diagnostics_for(uuid) - want = full.diagnostics_for(uuid) - assert got is not None and want is not None - assert sorted(map(repr, got.errors)) == sorted(map(repr, want.errors)) - assert sorted(map(repr, got.warnings)) == sorted( - map(repr, want.warnings) - ) diff --git a/tests/test_checker/test_graph.py b/tests/test_checker/test_graph.py deleted file mode 100644 index 996d488..0000000 --- a/tests/test_checker/test_graph.py +++ /dev/null @@ -1,252 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.checker.graph import ( - ConstraintGraph, - EdgeOrigin, - EdgeType, - PackageId, -) -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) -from pxmodrim.core.sort.config import SortSettings - - -def _make_mod(package_id: str) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = package_id - m._mod_path = None - return m - - -def _settings(*, use_alternative_package_ids: bool = False) -> SortSettings: - return SortSettings( - use_community_rules=False, - use_alternative_package_ids=use_alternative_package_ids, - ) - - -class TestConstraintGraph: - def test_empty_graph(self): - g = ConstraintGraph() - assert len(g.nodes) == 0 - - def test_build_no_edges(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - assert PackageId("mod.a") in g.nodes - assert PackageId("mod.b") in g.nodes - assert len(g.nodes) == 2 - assert g.index_of(PackageId("mod.a")) == 0 - assert g.index_of(PackageId("mod.b")) == 1 - - def test_dependency_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {PackageId("mod.b"): dep} - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.DEPENDENCY) - assert len(edges) == 1 - edge = next(iter(edges)) - assert edge.source == PackageId("mod.a") - assert edge.target == PackageId("mod.b") - assert edge.type == EdgeType.DEPENDENCY - assert edge.origin == EdgeOrigin.ABOUT_XML - - def test_dependency_alternative_resolution_respects_setting(self): - g = ConstraintGraph() - consumer = _make_mod("mod.consumer") - alternative = _make_mod("mod.alternative") - dependency = DependencyMod() - dependency.package_id = CaseInsensitiveStr("mod.dependency") - dependency.alternative_package_ids = {CaseInsensitiveStr("mod.alternative")} - consumer.about_rules.dependencies = {PackageId("mod.dependency"): dependency} - mods = { - PackageId("mod.consumer"): consumer, - PackageId("mod.alternative"): alternative, - } - ordered_pids = list(mods) - - g.build( - mods, - ordered_pids, - _settings(use_alternative_package_ids=True), - ) - enabled_targets = { - edge.target - for edge in g.edges_of_type(PackageId("mod.consumer"), EdgeType.DEPENDENCY) - } - assert enabled_targets == {PackageId("mod.alternative")} - - g.build( - mods, - ordered_pids, - _settings(use_alternative_package_ids=False), - ) - disabled_targets = { - edge.target - for edge in g.edges_of_type(PackageId("mod.consumer"), EdgeType.DEPENDENCY) - } - assert disabled_targets == {PackageId("mod.dependency")} - - def test_incompatibility_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.INCOMPATIBILITY) - assert len(edges) == 1 - assert next(iter(edges)).target == PackageId("mod.b") - - def test_load_after_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.LOAD_AFTER) - assert len(edges) == 1 - assert next(iter(edges)).target == PackageId("mod.b") - - def test_load_before_edge(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_before = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - edges = g.edges_of_type(PackageId("mod.a"), EdgeType.LOAD_BEFORE) - assert len(edges) == 1 - assert next(iter(edges)).target == PackageId("mod.b") - - def test_neighbors(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - nbrs = g.neighbors(PackageId("mod.a")) - assert PackageId("mod.b") in nbrs - nbrs_b = g.neighbors(PackageId("mod.b")) - assert PackageId("mod.a") in nbrs_b - - def test_incoming(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - - incoming = g.incoming(PackageId("mod.b")) - assert len(incoming) == 1 - assert next(iter(incoming)).source == PackageId("mod.a") - - def test_add_remove_node(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - assert PackageId("mod.b") in g.nodes - - g.remove_mod(PackageId("mod.b")) - assert PackageId("mod.b") not in g.nodes - # The edge from 'a' to the removed node should be gone too - assert not g.outgoing(PackageId("mod.a")) - - def test_update_order(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, [PackageId("mod.a"), PackageId("mod.b")], _settings()) - assert g.index_of(PackageId("mod.a")) == 0 - assert g.index_of(PackageId("mod.b")) == 1 - - g.update_order([PackageId("mod.b"), PackageId("mod.a")]) - assert g.index_of(PackageId("mod.a")) == 1 - assert g.index_of(PackageId("mod.b")) == 0 - - def test_cycle_detection(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - c = _make_mod("mod.c") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - b.about_rules.load_after = CaseInsensitiveSet(["mod.c"]) - c.about_rules.load_after = CaseInsensitiveSet(["mod.a"]) - mods = { - PackageId("mod.a"): a, - PackageId("mod.b"): b, - PackageId("mod.c"): c, - } - g.build(mods, list(mods.keys()), _settings()) - cycles = g.find_cycles() - assert len(cycles) >= 1 - cycle_pids = {str(p) for cycle in cycles for p in cycle} - for pid in ("mod.a", "mod.b", "mod.c"): - assert pid in cycle_pids - - def test_cycle_detection_reports_overlapping_cycles(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - c = _make_mod("mod.c") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - b.about_rules.load_after = CaseInsensitiveSet(["mod.a", "mod.c"]) - c.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = { - PackageId("mod.a"): a, - PackageId("mod.b"): b, - PackageId("mod.c"): c, - } - g.build(mods, list(mods), _settings()) - - cycles = g.find_cycles() - - assert len(cycles) == 1 - assert set(cycles[0]) == set(mods) - - def test_no_cycle(self): - g = ConstraintGraph() - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - g.build(mods, list(mods.keys()), _settings()) - cycles = g.find_cycles() - assert len(cycles) == 0 - - def test_cycle_detection_handles_long_chain(self): - nodes = [PackageId(f"mod.{index}") for index in range(5000)] - mods = {pid: _make_mod(str(pid)) for pid in nodes} - for index in range(len(nodes) - 1): - mods[nodes[index]].about_rules.load_after = CaseInsensitiveSet( - [str(nodes[index + 1])] - ) - g = ConstraintGraph() - g.build(mods, nodes, _settings()) - - assert g.find_cycles() == [] diff --git a/tests/test_checker/test_issues.py b/tests/test_checker/test_issues.py deleted file mode 100644 index 62d5f77..0000000 --- a/tests/test_checker/test_issues.py +++ /dev/null @@ -1,312 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from pxmodrim.core.checker.graph import ConstraintGraph, EdgeOrigin, EdgeType -from pxmodrim.core.checker.issues import ( - CycleIssueChecker, - DependencyIssueChecker, - GameVersionIssueChecker, - IncompatibilityIssueChecker, - LoadOrderIssueChecker, - ReplacementIssueChecker, -) -from pxmodrim.core.checker.models import CheckContext, PackageId -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, -) -from pxmodrim.core.sort.config import SortSettings -from pxmodrim.core.sort.models import CommunityRule - - -def _make_mod(package_id: str) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = package_id - m._mod_path = None - return m - - -def _settings() -> SortSettings: - return SortSettings( - use_community_rules=False, - use_alternative_package_ids=True, - check_missing_dependencies=True, - ) - - -def _ctx( - mods: dict[PackageId, AboutXmlMod], - ordered_pids: list[PackageId] | None = None, - graph: ConstraintGraph | None = None, - settings: SortSettings | None = None, - **kwargs: Any, -) -> CheckContext: - if ordered_pids is None: - ordered_pids = list(mods.keys()) - if graph is None: - graph = ConstraintGraph() - graph.build(mods, ordered_pids, settings or _settings()) - pid_to_index = {p: i for i, p in enumerate(ordered_pids)} - return CheckContext( - active_mods=mods, - ordered_pids=ordered_pids, - pid_to_index=pid_to_index, - graph=graph, - settings=settings or _settings(), - **kwargs, - ) - - -class TestDependencyIssueChecker: - def test_no_issues_no_deps(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = DependencyIssueChecker() - assert not checker.should_check(a, ctx) - assert checker.check(a, ctx) == [] - - def test_missing_dep(self): - a = _make_mod("mod.a") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {PackageId("mod.b"): dep} - # b is not added to active_mods — mimics missing dependency - ctx = CheckContext( - active_mods={PackageId("mod.a"): a}, - ordered_pids=[PackageId("mod.a")], - pid_to_index={PackageId("mod.a"): 0}, - graph=ConstraintGraph(), - settings=_settings(), - ) - checker = DependencyIssueChecker() - assert checker.should_check(a, ctx) - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "missing_dependency" - assert issues[0].severity == "error" - - def test_dep_satisfied(self): - a = _make_mod("mod.a") - dep = DependencyMod() - dep.package_id = CaseInsensitiveStr("mod.b") - a.about_rules.dependencies = {PackageId("mod.b"): dep} - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = DependencyIssueChecker() - assert checker.check(a, ctx) == [] - - -class TestIncompatibilityIssueChecker: - def test_no_incompatibilities(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = IncompatibilityIssueChecker() - assert checker.check(a, ctx) == [] - - def test_self_declared(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.incompatible_with = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx(mods) - checker = IncompatibilityIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "incompatibility" - assert issues[0].severity == "error" - - def test_reverse_declared(self): - declarer = _make_mod("mod.declarer") - target = _make_mod("mod.target") - declarer.about_rules.incompatible_with = CaseInsensitiveSet(["mod.target"]) - mods = { - PackageId("mod.declarer"): declarer, - PackageId("mod.target"): target, - } - ctx = _ctx(mods) - checker = IncompatibilityIssueChecker() - - issues = checker.check(target, ctx) - - assert len(issues) == 1 - assert issues[0].category == "incompatibility" - assert issues[0].related_package_ids == (PackageId("mod.declarer"),) - - def test_reverse_declared_is_deduplicated_across_origins(self): - declarer = _make_mod("mod.declarer") - target = _make_mod("mod.target") - declarer_pid = PackageId("mod.declarer") - target_pid = PackageId("mod.target") - declarer.about_rules.incompatible_with = CaseInsensitiveSet([target_pid]) - mods = {declarer_pid: declarer, target_pid: target} - settings = SortSettings( - use_community_rules=True, - use_alternative_package_ids=True, - check_missing_dependencies=True, - ) - community_rules = { - declarer_pid: CommunityRule( - package_id=declarer_pid, - load_after=set(), - load_before=set(), - load_first=False, - load_last=False, - incompatible_with={target_pid}, - ) - } - graph = ConstraintGraph() - graph.build(mods, list(mods), settings, community_rules) - origins = { - edge.origin - for edge in graph.incoming_of_type(target_pid, EdgeType.INCOMPATIBILITY) - } - assert origins == {EdgeOrigin.ABOUT_XML, EdgeOrigin.COMMUNITY_RULES} - ctx = _ctx(mods, graph=graph, settings=settings) - - issues = IncompatibilityIssueChecker().check(target, ctx) - - assert len(issues) == 1 - assert issues[0].related_package_ids == (declarer_pid,) - - -class TestLoadOrderIssueChecker: - def test_no_violations(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ordered = [PackageId("mod.b"), PackageId("mod.a")] - ctx = _ctx(mods, ordered_pids=ordered) - checker = LoadOrderIssueChecker() - issues = checker.check(a, ctx) - assert issues == [] - - def test_load_after_violation(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_after = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ordered = [PackageId("mod.a"), PackageId("mod.b")] - ctx = _ctx(mods, ordered_pids=ordered) - checker = LoadOrderIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "load_order" - assert issues[0].severity == "warning" - - def test_load_before_violation(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - a.about_rules.load_before = CaseInsensitiveSet(["mod.b"]) - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - # A should load before B but appears after B - ordered = [PackageId("mod.b"), PackageId("mod.a")] - ctx = _ctx(mods, ordered_pids=ordered) - checker = LoadOrderIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "load_order" - assert issues[0].severity == "warning" - - -class TestCycleIssueChecker: - def test_no_cycle(self): - a = _make_mod("mod.a") - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, cycles=[]) - checker = CycleIssueChecker() - assert checker.check(a, ctx) == [] - - def test_cycle_member(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b} - ctx = _ctx( - mods, - cycles=[ - [PackageId("mod.a"), PackageId("mod.b")], - ], - ) - checker = CycleIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "cycle" - assert issues[0].severity == "warning" - - def test_non_member_no_issue(self): - a = _make_mod("mod.a") - b = _make_mod("mod.b") - c = _make_mod("mod.c") - mods = {PackageId("mod.a"): a, PackageId("mod.b"): b, PackageId("mod.c"): c} - ctx = _ctx( - mods, - cycles=[ - [PackageId("mod.a"), PackageId("mod.b")], - ], - ) - checker = CycleIssueChecker() - assert checker.check(c, ctx) == [] - - -class TestGameVersionIssueChecker: - def test_no_versions_no_issue(self): - a = _make_mod("mod.a") - a.supported_versions = set() - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods) - checker = GameVersionIssueChecker() - assert not checker.should_check(a, ctx) - assert checker.check(a, ctx) == [] - - def test_version_match(self): - a = _make_mod("mod.a") - a.supported_versions = {"1.5"} - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, target_version="1.5") - checker = GameVersionIssueChecker() - assert checker.check(a, ctx) == [] - - def test_version_mismatch(self): - a = _make_mod("mod.a") - a.supported_versions = {"1.4"} - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, target_version="1.5") - checker = GameVersionIssueChecker() - issues = checker.check(a, ctx) - assert len(issues) == 1 - assert issues[0].category == "version_mismatch" - assert issues[0].severity == "warning" - - def test_no_version_warning_suppressed(self): - a = _make_mod("mod.a") - a.supported_versions = {"1.4"} - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods, target_version="1.5", no_version_warning={PackageId("mod.a")}) - checker = GameVersionIssueChecker() - assert checker.check(a, ctx) == [] - - -class TestReplacementIssueChecker: - def test_no_replacement(self): - a = _make_mod("mod.a") - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods) - checker = ReplacementIssueChecker() - assert checker.check(a, ctx) == [] - - def test_replacement_available(self): - a = _make_mod("mod.a") - a._mod_path = None # No published_file_id - mods = {PackageId("mod.a"): a} - ctx = _ctx(mods) - checker = ReplacementIssueChecker() - assert checker.check(a, ctx) == [] diff --git a/tests/test_compat/test_config.py b/tests/test_compat/test_config.py deleted file mode 100644 index 6857c8c..0000000 --- a/tests/test_compat/test_config.py +++ /dev/null @@ -1,240 +0,0 @@ -from __future__ import annotations - -import json -import sys -from pathlib import Path - -import pytest - -import pxmodrim.core.config as config_module -from pxmodrim.core.config import ( - AppConfig, - AppConfigService, - ConfigService, - PathConfig, - read_game_version, -) -from pxmodrim.core.constants import LaunchStrategy -from pxmodrim.ui.config import UIPrefsService -from pxmodrim.ui.ui_prefs import UIPrefs - - -class TestReadGameVersion: - def test_parses_file(self, tmp_path: Path) -> None: - ver_file = tmp_path / "Version.txt" - ver_file.write_text("1.6.4871 rev598\n", encoding="utf-8") - result = read_game_version(tmp_path) - assert result == "1.6.4871 rev598" - - def test_strips_whitespace(self, tmp_path: Path) -> None: - ver_file = tmp_path / "Version.txt" - ver_file.write_text(" 1.6.4871 rev598 \n", encoding="utf-8") - result = read_game_version(tmp_path) - assert result == "1.6.4871 rev598" - - def test_missing_file_returns_none(self, tmp_path: Path) -> None: - result = read_game_version(tmp_path) - assert result is None - - @pytest.mark.skipif( - sys.platform == "win32", reason="chmod has no effect on Windows" - ) - def test_unreadable_file_returns_none(self, tmp_path: Path) -> None: - ver_file = tmp_path / "Version.txt" - ver_file.write_text("1.6.4871 rev598\n") - ver_file.chmod(0o000) - try: - result = read_game_version(tmp_path) - assert result is None - finally: - ver_file.chmod(0o644) - - -async def test_legacy_app_config_migrates_with_backup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - service = ConfigService(tmp_path) - expected = AppConfig( - paths=PathConfig( - game="/legacy/game", - local="/legacy/mods", - workshop="/legacy/workshop", - config_folder="/legacy/config", - community_rules_file="/legacy/rules", - no_version_warning_file="/legacy/no-warning", - use_this_instead_file="/legacy/use-instead", - ) - ) - service.save("config.json", expected) - path = tmp_path / "config.json" - legacy = json.loads(path.read_text()) - legacy.pop("schema_version") - legacy["paths"]["steamcmd_prefix"] = "/legacy/steamcmd" - path.write_text(json.dumps(legacy), encoding="utf-8") - before = path.read_bytes() - monkeypatch.setattr(config_module, "time", lambda: 1_700_000_000) - - app_service = AppConfigService(service) - await app_service.setup() - - assert app_service.cfg() == expected - assert json.loads(path.read_text())["schema_version"] == 1 - assert (tmp_path / "config.json.bak.1700000000").read_bytes() == before - - -async def test_legacy_ui_prefs_migrates_with_backup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - service = ConfigService(tmp_path) - expected = UIPrefs( - desc_expanded=True, - launch_strategy=LaunchStrategy.STEAM, - ) - service.save("ui_prefs.json", expected) - path = tmp_path / "ui_prefs.json" - legacy = json.loads(path.read_text()) - legacy.pop("schema_version") - legacy["validate_downloads"] = True - path.write_text(json.dumps(legacy), encoding="utf-8") - before = path.read_bytes() - monkeypatch.setattr(config_module, "time", lambda: 1_700_000_000) - - prefs_service = UIPrefsService(service) - await prefs_service.setup() - - assert prefs_service.prefs() == expected - assert json.loads(path.read_text())["schema_version"] == 1 - assert (tmp_path / "ui_prefs.json.bak.1700000000").read_bytes() == before - - -async def test_fresh_services_return_defaults_without_creating_files( - tmp_path: Path, -) -> None: - service = ConfigService(tmp_path) - app_service = AppConfigService(service) - prefs_service = UIPrefsService(service) - await app_service.setup() - await prefs_service.setup() - - assert app_service.cfg() == AppConfig() - assert prefs_service.prefs() == UIPrefs() - assert not (tmp_path / "config.json").exists() - assert not (tmp_path / "ui_prefs.json").exists() - - -def test_managed_saves_stamp_schema_version_and_round_trips_values( - tmp_path: Path, -) -> None: - service = ConfigService(tmp_path) - expected_config = AppConfig(paths=PathConfig(game="/game", local="/mods")) - expected_prefs = UIPrefs(desc_expanded=True, launch_strategy=LaunchStrategy.STEAM) - service.save("config.json", expected_config) - service.save("ui_prefs.json", expected_prefs) - - assert json.loads((tmp_path / "config.json").read_text())["schema_version"] == 1 - assert json.loads((tmp_path / "ui_prefs.json").read_text())["schema_version"] == 1 - assert service.load("config.json", AppConfig) == expected_config - assert service.load("ui_prefs.json", UIPrefs) == expected_prefs - - -def test_current_config_load_does_not_rewrite(tmp_path: Path) -> None: - path = tmp_path / "config.json" - path.write_text( - json.dumps({"schema_version": 1, "paths": {"game": "/current"}}), - encoding="utf-8", - ) - before = path.read_bytes() - - assert ( - ConfigService(tmp_path).load("config.json", AppConfig).paths.game == "/current" - ) - assert path.read_bytes() == before - assert not list(tmp_path.glob("config.json.bak.*")) - - -def test_current_ui_prefs_load_does_not_rewrite(tmp_path: Path) -> None: - path = tmp_path / "ui_prefs.json" - path.write_text( - json.dumps({"schema_version": 1, "desc_expanded": True}), - encoding="utf-8", - ) - before = path.read_bytes() - - assert ConfigService(tmp_path).load("ui_prefs.json", UIPrefs).desc_expanded is True - assert path.read_bytes() == before - assert not list(tmp_path.glob("ui_prefs.json.bak.*")) - - -def test_corrupt_config_returns_safe_defaults(tmp_path: Path) -> None: - (tmp_path / "config.json").write_text("{invalid", encoding="utf-8") - - assert ConfigService(tmp_path).load("config.json", AppConfig) == AppConfig() - - -def test_unreadable_config_returns_safe_defaults( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - path = tmp_path / "config.json" - path.write_text("{}", encoding="utf-8") - - def unreadable(_: Path) -> bytes: - raise OSError("permission denied") - - monkeypatch.setattr(Path, "read_bytes", unreadable) - assert ConfigService(tmp_path).load("config.json", AppConfig) == AppConfig() - - -def test_missing_json_migration_step_fails_without_rewrite( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - service = ConfigService(tmp_path) - service.save("config.json", AppConfig()) - path = tmp_path / "config.json" - legacy = json.loads(path.read_text()) - legacy.pop("schema_version") - path.write_text(json.dumps(legacy), encoding="utf-8") - before = path.read_bytes() - monkeypatch.delitem(config_module._JSON_MIGRATIONS, 1) - - with pytest.raises(KeyError, match="Missing JSON migration step"): - service.load("config.json", AppConfig) - - assert path.read_bytes() == before - assert not list(tmp_path.glob("config.json.bak.*")) - - -def test_unsupported_config_schema_fails_without_mutation(tmp_path: Path) -> None: - path = tmp_path / "config.json" - path.write_text(json.dumps({"schema_version": 2}), encoding="utf-8") - before = path.read_bytes() - - with pytest.raises(ValueError, match="Unsupported JSON schema version"): - ConfigService(tmp_path).load("config.json", AppConfig) - - assert path.read_bytes() == before - - -def test_ui_prefs_skipped_update_tag_round_trips(tmp_path: Path) -> None: - service = ConfigService(tmp_path) - service.save("ui_prefs.json", UIPrefs(skipped_update_tag="v0.2.0")) - - assert service.load("ui_prefs.json", UIPrefs).skipped_update_tag == "v0.2.0" - - -def test_ui_prefs_with_removed_key_still_loads(tmp_path: Path) -> None: - (tmp_path / "ui_prefs.json").write_text( - json.dumps( - { - "schema_version": 1, - "deps_expanded": True, - "desc_expanded": True, - "skipped_update_tag": "v0.3.0", - } - ), - encoding="utf-8", - ) - - prefs = ConfigService(tmp_path).load("ui_prefs.json", UIPrefs) - - assert prefs.desc_expanded is True - assert prefs.skipped_update_tag == "v0.3.0" diff --git a/tests/test_compat/test_migrator.py b/tests/test_compat/test_migrator.py deleted file mode 100644 index c4c9665..0000000 --- a/tests/test_compat/test_migrator.py +++ /dev/null @@ -1,192 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import aiosqlite -import pytest - -import pxmodrim.core.migrator as migrator_module -from pxmodrim.core.migrator import ensure_schema - - -async def _noop() -> None: - return None - - -async def test_db_fresh_init_creates_schema_and_stamps_target( - tmp_path: Path, -) -> None: - path = tmp_path / "cache.db" - events: list[str] = [] - - async def future_step() -> None: - events.append("future") - - async with aiosqlite.connect(path) as conn: - await ensure_schema( - conn, - schema_sql="CREATE TABLE state (value TEXT);", - schema_version=2, - steps={1: _noop, 2: future_step}, - backup_path=path, - ) - await conn.commit() - - version = await (await conn.execute("PRAGMA user_version")).fetchone() - table = await ( - await conn.execute( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'state'" - ) - ).fetchone() - - assert version == (2,) - assert table == ("state",) - assert events == [] - - assert not list(tmp_path.glob("cache.db.bak.*")) - - -async def test_db_migrations_checkpoint_wal_before_backup( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - path = tmp_path / "cache.db" - monkeypatch.setattr(migrator_module, "time", lambda: 1_700_000_000) - backup_path = tmp_path / "cache.db.bak.1700000000" - - async with aiosqlite.connect(path) as conn: - journal_mode = await ( - await conn.execute("PRAGMA journal_mode = WAL") - ).fetchone() - assert journal_mode == ("wal",) - await conn.execute("PRAGMA wal_autocheckpoint = 0") - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("INSERT INTO state VALUES ('before')") - await conn.execute("PRAGMA user_version = 1") - await conn.commit() - assert path.with_name(f"{path.name}-wal").stat().st_size > 0 - events: list[str] = [] - - async def step_two() -> None: - events.append("two") - await conn.execute("ALTER TABLE state ADD COLUMN second TEXT") - - async def step_three() -> None: - events.append("three") - await conn.execute("ALTER TABLE state ADD COLUMN third TEXT") - - await ensure_schema( - conn, - schema_sql="CREATE TABLE IF NOT EXISTS state (value TEXT);", - schema_version=3, - steps={2: step_two, 3: step_three}, - backup_path=path, - ) - await conn.commit() - - version = await (await conn.execute("PRAGMA user_version")).fetchone() - columns = await (await conn.execute("PRAGMA table_info(state)")).fetchall() - - assert version == (3,) - assert [column[1] for column in columns] == ["value", "second", "third"] - assert events == ["two", "three"] - - for suffix in ("-wal", "-shm"): - backup_path.with_name(f"{backup_path.name}{suffix}").unlink(missing_ok=True) - - async with aiosqlite.connect(backup_path) as backup_conn: - backup_version = await ( - await backup_conn.execute("PRAGMA user_version") - ).fetchone() - backup_columns = await ( - await backup_conn.execute("PRAGMA table_info(state)") - ).fetchall() - backup_rows = await ( - await backup_conn.execute("SELECT value FROM state") - ).fetchall() - - assert backup_version == (1,) - assert [column[1] for column in backup_columns] == ["value"] - assert backup_rows == [("before",)] - - -async def test_db_missing_migration_step_fails_without_advancing_version( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - path = tmp_path / "cache.db" - monkeypatch.setattr(migrator_module, "time", lambda: 1_700_000_000) - backup_path = tmp_path / "cache.db.bak.1700000000" - - async with aiosqlite.connect(path) as conn: - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("PRAGMA user_version = 1") - await conn.commit() - before = path.read_bytes() - - with pytest.raises(KeyError, match="Missing migration step"): - await ensure_schema( - conn, - schema_sql="CREATE TABLE IF NOT EXISTS state (value TEXT);", - schema_version=3, - steps={2: _noop}, - backup_path=path, - ) - version = await (await conn.execute("PRAGMA user_version")).fetchone() - - assert version == (1,) - - assert path.read_bytes() == before - assert backup_path.read_bytes() == before - - -async def test_db_current_schema_is_a_noop(tmp_path: Path) -> None: - path = tmp_path / "cache.db" - async with aiosqlite.connect(path) as conn: - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("PRAGMA user_version = 1") - await conn.commit() - before = path.read_bytes() - events: list[str] = [] - - async def unexpected_step() -> None: - events.append("called") - - await ensure_schema( - conn, - schema_sql="CREATE TABLE should_not_be_created (value TEXT);", - schema_version=1, - steps={1: unexpected_step}, - backup_path=path, - ) - - assert await (await conn.execute("PRAGMA user_version")).fetchone() == (1,) - assert events == [] - - assert path.read_bytes() == before - assert not list(tmp_path.glob("cache.db.bak.*")) - - -async def test_db_newer_schema_is_a_noop(tmp_path: Path) -> None: - path = tmp_path / "cache.db" - async with aiosqlite.connect(path) as conn: - await conn.execute("CREATE TABLE state (value TEXT)") - await conn.execute("PRAGMA user_version = 2") - await conn.commit() - before = path.read_bytes() - events: list[str] = [] - - async def unexpected_step() -> None: - events.append("called") - - await ensure_schema( - conn, - schema_sql="CREATE TABLE should_not_be_created (value TEXT);", - schema_version=1, - steps={1: unexpected_step}, - backup_path=path, - ) - - assert await (await conn.execute("PRAGMA user_version")).fetchone() == (2,) - assert events == [] - - assert path.read_bytes() == before - assert not list(tmp_path.glob("cache.db.bak.*")) diff --git a/tests/test_downloads/test_manager.py b/tests/test_downloads/test_manager.py deleted file mode 100644 index f7ad274..0000000 --- a/tests/test_downloads/test_manager.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.downloads import DownloadManager, DownloadResult -from pxmodrim.core.downloads.downloader import Downloader -from pxmodrim.core.models.metadata.structures import ListedMod - - -class FakeDownloader(Downloader): - prefix = "" - - def __init__(self) -> None: - super().__init__() - self._busy = False - self.calls: list[list[str]] = [] - self.cancelled = False - - @property - def is_downloading(self) -> bool: - return self._busy - - @property - def active_ids(self) -> frozenset[str]: - return frozenset() - - def accepts(self, mod_id: str) -> bool: - return mod_id.startswith(self.prefix) - - def updatable_id(self, mod: ListedMod) -> str | None: - mod_id = mod.mod_path.name if mod.mod_path else "" - return mod_id if self.accepts(mod_id) else None - - def last_synced(self, mod: ListedMod) -> float | None: - return 1.0 if self.updatable_id(mod) else None - - async def download_mods(self, mod_ids: list[str]) -> DownloadResult: - self.calls.append(mod_ids) - self._busy = True - self.busy_changed.emit(True) - self._busy = False - self.busy_changed.emit(False) - result = DownloadResult(succeeded=list(mod_ids), failed=[]) - self.download_finished.emit(result) - return result - - def cancel(self) -> None: - self.cancelled = True - - -class FakeSteam(FakeDownloader): - name = "steam" - label = "Steam" - prefix = "s" - - -class FakeGithub(FakeDownloader): - name = "github" - label = "Github" - prefix = "g" - - -def _mod(tmp_path, name: str) -> ListedMod: - return ListedMod(_mod_path=tmp_path / name) - - -def test_empty_manager_offers_nothing(tmp_path) -> None: - manager = DownloadManager() - assert not manager.available - assert manager.updatable_ids([_mod(tmp_path, "s1")]) == [] - assert manager.source_of(_mod(tmp_path, "s1")) is None - - -async def test_download_routes_ids_to_the_accepting_downloader() -> None: - manager = DownloadManager() - steam, github = FakeSteam(), FakeGithub() - manager.register(steam) - manager.register(github) - - result = await manager.download_mods(["s1", "g1", "s2", "s1"]) - - assert steam.calls == [["s1", "s2"]] - assert github.calls == [["g1"]] - assert sorted(result.succeeded) == ["g1", "s1", "s2"] - - -async def test_unclaimed_id_is_rejected_before_anything_starts() -> None: - manager = DownloadManager() - steam = FakeSteam() - manager.register(steam) - - with pytest.raises(ValueError, match="x1"): - await manager.download_mods(["s1", "x1"]) - assert steam.calls == [] - - -def test_source_and_ids_come_from_the_owning_downloader(tmp_path) -> None: - manager = DownloadManager() - manager.register(FakeSteam()) - manager.register(FakeGithub()) - mods = [_mod(tmp_path, n) for n in ("s1", "g1", "other")] - - source = manager.source_of(mods[1]) - assert source is not None and source.label == "Github" - assert manager.updatable_ids(mods) == ["s1", "g1"] - assert manager.last_synced(mods[2]) is None - - -async def test_busy_reflects_any_downloader_and_cancel_stops_the_rest() -> None: - manager = DownloadManager() - steam, github = FakeSteam(), FakeGithub() - manager.register(steam) - manager.register(github) - busy: list[bool] = [] - manager.busy_changed.connect(busy.append) - - steam.download_finished.connect(lambda _r: manager.cancel()) - await manager.download_mods(["s1", "g1"]) - - assert github.calls == [] - assert busy == [True, False] - - -def test_names_are_keyed_by_source_id_not_workshop_id(tmp_path) -> None: - manager = DownloadManager() - manager.register(FakeGithub()) - mod = _mod(tmp_path, "g1") - object.__setattr__(mod, "name", "Git Mod") - - assert manager.names_by_id([mod, _mod(tmp_path, "s1")]) == {"g1": "Git Mod"} diff --git a/tests/test_downloads/test_steam.py b/tests/test_downloads/test_steam.py deleted file mode 100644 index 45b0ed9..0000000 --- a/tests/test_downloads/test_steam.py +++ /dev/null @@ -1,506 +0,0 @@ -from __future__ import annotations - -import asyncio -import threading -from collections.abc import Callable, Sequence -from os import PathLike -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import pxsteamdl -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult -from pxmodrim.core.downloads.manager import download_manager -from pxmodrim.core.downloads.steam import ( - ClientOptions, - SteamDownloader, - SteamSettings, - WorkshopSyncState, -) -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.workshop import DownloadPlan, WorkshopCatalog - - -class FakeClient: - def __init__( - self, - errors: dict[int, str] | None = None, - gates: dict[int, threading.Event] | None = None, - network_failures: frozenset[int] = frozenset(), - ) -> None: - self.errors = errors or {} - self.gates = gates or {} - self.network_failures = network_failures - self.stopped = False - self.calls: list[tuple[list[int], Path, int]] = [] - self.blocked = threading.Event() - - def download( - self, - ids: Sequence[int], - root: str | PathLike[str], - *, - parallel_items: int = 4, - threads_per_item: int = 8, - on_progress: Callable[[Any], None] | None = None, - on_resolved: Callable[[Any], None] | None = None, - cancel: Any = None, - ) -> list[Any]: - self.calls.append((list(ids), Path(root), parallel_items * threads_per_item)) - results = [] - for item_id in ids: - if on_resolved is not None: - on_resolved( - SimpleNamespace(item_id=item_id, title=f"Title {item_id}", error="") - ) - if on_progress is not None: - on_progress( - SimpleNamespace( - item_id=item_id, unpacked_bytes=5, unpacked_total=10 - ) - ) - gate = self.gates.get(item_id) - if gate is not None: - self.blocked.set() - gate.wait(timeout=5) - error = "cancelled" if self.stopped else self.errors.get(item_id, "") - if not error: - (Path(root) / str(item_id)).mkdir(parents=True, exist_ok=True) - results.append( - SimpleNamespace( - item_id=item_id, - error=error, - cancelled=self.stopped, - error_kind=( - pxsteamdl.ErrorKind.NETWORK - if item_id in self.network_failures - else pxsteamdl.ErrorKind.NONE - ), - ) - ) - return results - - -def _cfg(tmp_path: Path, *, local: bool = True) -> AppConfig: - cfg = AppConfig() - if local: - cfg.paths.local = str(tmp_path / "Mods") - return cfg - - -def _service( - tmp_path: Path, client: FakeClient | None = None, *, local: bool = True -) -> tuple[SteamDownloader, list[ClientOptions]]: - cfg = _cfg(tmp_path, local=local) - logins: list[ClientOptions] = [] - - async def factory(options: ClientOptions) -> FakeClient: - logins.append(options) - if client is None: - raise RuntimeError("logon denied") - return client - - svc = SteamDownloader(factory) - ctx = CoreContext(cfg, ConfigService(tmp_path)) - svc.setup(ctx) - return svc, logins - - -async def test_login_uses_configured_proxy_and_timeouts(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient()) - assert svc._ctx is not None - await svc.download_mods(["111"]) - svc.settings.update( - SteamSettings(proxy=" socks5h://host:1080 ", connect_timeout=3, stall_timeout=7) - ) - await svc.download_mods(["222"]) - assert logins == [(None, 10, 30), ("socks5h://host:1080", 3, 7)] - - -async def test_network_failure_triggers_new_login(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient(network_failures=frozenset({111}))) - await svc.download_mods(["111"]) - await svc.download_mods(["222"]) - assert len(logins) == 2 - - -async def test_downloads_into_local_mods_and_splits_results(tmp_path: Path) -> None: - client = FakeClient(errors={222: "item not found"}) - svc, _ = _service(tmp_path, client) - statuses: list[DownloadItemStatus] = [] - finished: list[DownloadResult] = [] - svc.download_item_status_changed.connect(statuses.append) - svc.download_finished.connect(finished.append) - - result = await svc.download_mods(["111", "222", "111"]) - - assert sorted(result.succeeded) == ["111"] - assert result.failed == ["222"] - assert finished == [result] - assert (tmp_path / "Mods" / "111").is_dir() - assert client.calls[0][0] == [111, 222] - assert {call[1] for call in client.calls} == {tmp_path / "Mods"} - final = {s.mod_id: s for s in statuses if s.status != "downloading"} - assert final["111"].status == "success" - assert final["222"].status == "error" - assert final["222"].error == "item not found" - - -@pytest.mark.parametrize( - "ids", [["12a"], ["١٢٣"], ["1 2"], [], ["18446744073709551616"]] -) -async def test_rejects_invalid_ids(tmp_path: Path, ids: list[str]) -> None: - svc, logins = _service(tmp_path, FakeClient()) - with pytest.raises(ValueError): - await svc.download_mods(ids) - assert logins == [] - - -async def test_requires_local_mods_path(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient(), local=False) - with pytest.raises(ValueError, match="Local mods path"): - await svc.download_mods(["111"]) - assert logins == [] - - -async def test_accepts_uint64_max_id(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - result = await svc.download_mods(["18446744073709551615"]) - assert result.succeeded == ["18446744073709551615"] - assert client.calls[0][0] == [2**64 - 1] - - -async def test_unwritable_local_path_fails_every_item(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient()) - (tmp_path / "Mods").write_text("not a directory") - result = await svc.download_mods(["111", "222"]) - assert sorted(result.failed) == ["111", "222"] - assert logins == [] - - -async def test_login_failure_fails_every_item_and_retries_next_time( - tmp_path: Path, -) -> None: - svc, logins = _service(tmp_path, None) - messages: list[str] = [] - svc.status_message_changed.connect(messages.append) - - result = await svc.download_mods(["111", "222"]) - await svc.download_mods(["111"]) - - assert result.succeeded == [] - assert sorted(result.failed) == ["111", "222"] - assert any("logon denied" in m for m in messages) - assert len(logins) == 2 - - -async def test_client_session_is_reused(tmp_path: Path) -> None: - svc, logins = _service(tmp_path, FakeClient()) - await svc.download_mods(["111"]) - await svc.download_mods(["222"]) - assert len(logins) == 1 - - -async def test_uses_at_most_eight_download_workers(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - await svc.download_mods([str(i) for i in range(1, 20)]) - assert len(client.calls) == 1 - assert client.calls[0][2] <= 8 - - -async def test_cancel_keeps_finished_items_and_returns(tmp_path: Path) -> None: - gate = threading.Event() - client = FakeClient(gates={222: gate}) - svc, _ = _service(tmp_path, client) - finished: list[DownloadResult] = [] - svc.download_finished.connect(finished.append) - - async def cancel_when_blocked() -> None: - while not client.blocked.is_set(): - await asyncio.sleep(0.01) - svc.cancel() - client.stopped = True - gate.set() - - canceller = asyncio.create_task(cancel_when_blocked()) - result = await svc.download_mods(["111", "222", "333"]) - await canceller - - assert result.succeeded == ["111"] - assert result.failed == [] - assert finished == [result] - assert not svc.is_downloading - - -async def test_second_download_while_running_is_rejected(tmp_path: Path) -> None: - gate = threading.Event() - svc, _ = _service(tmp_path, FakeClient(gates={111: gate})) - first = asyncio.create_task(svc.download_mods(["111"])) - while not svc.is_downloading: - await asyncio.sleep(0) - - with pytest.raises(RuntimeError, match="already running"): - await svc.download_mods(["222"]) - - gate.set() - assert (await first).succeeded == ["111"] - - -def _mod_at(path: Path, pfid: str | None = None) -> ListedMod: - if pfid is not None: - (path / "About").mkdir(parents=True) - (path / "About" / "PublishedFileId.txt").write_text(pfid) - else: - path.mkdir(parents=True) - return ListedMod(_mod_path=path) - - -def _synced(tmp_path: Path, svc: SteamDownloader, pid: str) -> float | None: - return svc.last_synced(ListedMod(_mod_path=tmp_path / "Mods" / pid)) - - -def _load(svc: SteamDownloader, mods: list[ListedMod]) -> None: - assert svc._ctx is not None - svc._ctx.load({m.uuid: m for m in mods}, []) - - -def test_updatable_id_only_for_downloads_in_local_mods(tmp_path: Path) -> None: - svc, _ = _service(tmp_path) - mods = [ - _mod_at(tmp_path / "Mods" / "111", "111"), - _mod_at(tmp_path / "Mods" / "Renamed", "222"), - _mod_at(tmp_path / "Mods" / "333"), - _mod_at(tmp_path / "Workshop" / "444", "444"), - ] - _load(svc, mods) - assert [svc.updatable_id(m) for m in mods] == ["111", None, "333", None] - assert svc._ctx is not None - assert svc.updatable_ids(svc._ctx.all_mods.values()) == ["111", "333"] - - -def test_updatable_id_none_without_local_path(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, local=False) - assert svc.updatable_id(_mod_at(tmp_path / "Mods" / "111", "111")) is None - - -async def test_busy_changed_brackets_download(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - events: list[object] = [] - svc.busy_changed.connect(lambda busy: events.append(("busy", busy))) - svc.download_finished.connect(lambda _r: events.append("finished")) - await svc.download_mods(["111"]) - assert events == [("busy", True), ("busy", False), "finished"] - - -async def test_invalid_ids_do_not_toggle_busy(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - busy: list[bool] = [] - svc.busy_changed.connect(busy.append) - with pytest.raises(ValueError): - await svc.download_mods(["12a"]) - assert busy == [] - - -def test_progress_counts_complete_and_up_to_date_items() -> None: - from pxmodrim.core.downloads.steam import _Batch - - batch = _Batch(3) - batch.bytes = {"1": (10, 10), "2": (5, 10), "3": (0, 0)} - assert batch.progress().completed == 2 - batch.succeeded.append("1") - assert batch.progress().completed == 2 - - -def test_updatable_ids_limited_to_given_mods_and_deduplicated(tmp_path: Path) -> None: - svc, _ = _service(tmp_path) - a = _mod_at(tmp_path / "Mods" / "111", "111") - b = _mod_at(tmp_path / "Mods" / "222", "222") - _load(svc, [a, b]) - assert svc.updatable_ids([b, b, _mod_at(tmp_path / "Other" / "9", "9")]) == ["222"] - - -async def test_result_changed_excludes_up_to_date_items(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - assert svc.active_ids == frozenset() - result = await svc.download_mods(["111"]) - assert result.changed == ["111"] - assert svc.active_ids == frozenset() - - -def test_batch_changed_ignores_zero_byte_items() -> None: - from pxmodrim.core.downloads.steam import _Batch - - batch = _Batch(2) - batch.succeeded = ["1", "2"] - batch.bytes = {"1": (0, 0), "2": (5, 5)} - assert batch.changed() == ["2"] - - -async def test_success_records_sync_time_and_persists(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient(errors={222: "gone"})) - await svc.download_mods(["111", "222"]) - assert _synced(tmp_path, svc, "111") is not None - assert _synced(tmp_path, svc, "222") is None - reloaded = ConfigService(tmp_path).load("workshop_sync.json", WorkshopSyncState) - assert set(reloaded.synced) == {"111"} - - -def test_stale_ids_never_synced_first_then_oldest(tmp_path: Path) -> None: - svc, _ = _service(tmp_path) - mods = [_mod_at(tmp_path / "Mods" / n, n) for n in ("1", "2", "3", "4")] - _load(svc, mods) - svc._sync.synced.update({"1": 1000.0, "2": 9000.0, "3": 500.0}) - assert svc.stale_ids(3600, now=10000.0) == ["4", "3", "1"] - - -async def test_auto_update_only_syncs_stale_mods_when_enabled(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - assert svc._ctx is not None - _load(svc, [_mod_at(tmp_path / "Mods" / "1", "1")]) - await svc._auto_update_once() - assert _synced(tmp_path, svc, "1") is None - - svc.settings.update(SteamSettings(auto_update_hours=6)) - await svc._auto_update_once() - assert _synced(tmp_path, svc, "1") is not None - synced = _synced(tmp_path, svc, "1") - await svc._auto_update_once() - assert _synced(tmp_path, svc, "1") == synced - - -@pytest.mark.parametrize("cancel_auto_update", [False, True]) -async def test_cancelled_manual_batch_does_not_discard_queue_after_auto_update( - tmp_path: Path, - cancel_auto_update: bool, -) -> None: - manual_gate = threading.Event() - auto_gate = threading.Event() - client = FakeClient(gates={111: manual_gate, 1: auto_gate}) - svc, _ = _service(tmp_path, client) - assert svc._ctx is not None - ctx = svc._ctx - ctx._mod_service = ModService(ctx, []) - _load(svc, [_mod_at(tmp_path / "Mods" / "1", "1")]) - svc.settings.update(SteamSettings(auto_update_hours=6)) - manager = download_manager(ctx) - catalog = WorkshopCatalog() - catalog.setup(ctx) - tasks: list[asyncio.Task[Any]] = [] - try: - manual = asyncio.create_task(manager.download_mods(["111"])) - tasks.append(manual) - assert await asyncio.to_thread(client.blocked.wait, 5) - manager.cancel() - client.stopped = True - manual_gate.set() - assert (await asyncio.wait_for(manual, 5)).succeeded == [] - assert manager.cancelled - - client.stopped = False - client.blocked.clear() - auto_update = asyncio.create_task(svc._auto_update_once()) - tasks.append(auto_update) - assert await asyncio.to_thread(client.blocked.wait, 5) - assert manager.is_downloading - catalog.enqueue(DownloadPlan(["222"], [], [], [], {}, True)) - runner = catalog._runner - assert runner is not None - await asyncio.sleep(0) - assert not runner.done() - assert catalog.queued_ids == {"222"} - if cancel_auto_update: - manager.cancel() - client.stopped = True - - auto_gate.set() - await asyncio.wait_for(auto_update, 5) - await asyncio.wait_for(runner, 5) - assert not catalog.queued_ids - if cancel_auto_update: - assert [call[0] for call in client.calls] == [[111], [1]] - assert manager.cancelled - assert _synced(tmp_path, svc, "222") is None - else: - assert [call[0] for call in client.calls] == [[111], [1], [222]] - assert not manager.cancelled - assert _synced(tmp_path, svc, "222") is not None - finally: - manual_gate.set() - auto_gate.set() - await asyncio.gather(*tasks, return_exceptions=True) - await catalog.shutdown() - await svc.shutdown() - - -async def test_titles_from_steam_are_emitted(tmp_path: Path) -> None: - svc, _ = _service(tmp_path, FakeClient()) - titles: dict[str, str] = {} - svc.download_item_titled.connect(lambda t: titles.update({t.mod_id: t.title})) - - phases: list[str] = [] - svc.download_phase_changed.connect(phases.append) - - await svc.download_mods(["111", "222"]) - - assert phases == ["login", "query", "run"] - assert titles == {"111": "Title 111", "222": "Title 222"} - - -async def test_client_is_relogged_after_download_raises(tmp_path: Path) -> None: - class Dying(FakeClient): - def download(self, ids, root, **kwargs): # type: ignore[no-untyped-def] - raise RuntimeError("session lost") - - clients: list[FakeClient] = [Dying(), FakeClient()] - logins: list[int] = [] - - async def factory(_options: ClientOptions) -> FakeClient: - logins.append(1) - return clients[len(logins) - 1] - - svc = SteamDownloader(factory) - svc.setup(CoreContext(_cfg(tmp_path), ConfigService(tmp_path))) - await svc.download_mods(["1"]) - await svc.download_mods(["2"]) - assert len(logins) == 2 - - -async def test_auto_update_loop_survives_unexpected_error( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr("pxmodrim.core.downloads.steam.AUTO_STARTUP_DELAY_S", 0) - monkeypatch.setattr("pxmodrim.core.downloads.steam.AUTO_CHECK_INTERVAL_S", 0) - svc, _ = _service(tmp_path, FakeClient()) - calls = 0 - - async def flaky() -> None: - nonlocal calls - calls += 1 - if calls == 1: - raise KeyError("boom") - - monkeypatch.setattr(svc, "_auto_update_once", flaky) - task = asyncio.create_task(svc._auto_update_loop()) - for _ in range(20): - await asyncio.sleep(0) - task.cancel() - assert calls >= 2 - - -async def test_download_uses_configured_parallelism_clamped(tmp_path: Path) -> None: - client = FakeClient() - svc, _ = _service(tmp_path, client) - assert svc._ctx is not None - svc.settings.update(SteamSettings(parallel_items=3, threads_per_item=5)) - await svc.download_mods(["1"]) - svc.settings.update(SteamSettings(parallel_items=0, threads_per_item=999)) - await svc.download_mods(["2"]) - assert [c[2] for c in client.calls] == [15, 16] diff --git a/tests/test_logging.py b/tests/test_logging.py deleted file mode 100644 index a443a68..0000000 --- a/tests/test_logging.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -from loguru import logger - -from pxmodrim._app import _configure_file_logging - - -def _write_record(tmp_path: Path, monkeypatch, level: str, message: str) -> dict: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("LOGURU_LEVEL", level) - sink_id = _configure_file_logging() - try: - logger.log(level, message) - finally: - logger.remove(sink_id) - - log_path = tmp_path / "pxmodrim" / "logs" / "pxmodrim.log" - return json.loads(log_path.read_text(encoding="utf-8").splitlines()[-1]) - - -def test_file_sink_writes_parseable_info_record(tmp_path: Path, monkeypatch) -> None: - event = _write_record(tmp_path, monkeypatch, "INFO", "startup context") - record = event["record"] - - assert record["level"]["name"] == "INFO" - assert record["message"] == "startup context" - assert record["process"]["id"] > 0 - assert record["time"]["timestamp"] > 0 - assert isinstance(record["line"], int) - - -def test_file_sink_defaults_to_info(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("LOGURU_LEVEL", raising=False) - sink_id = _configure_file_logging() - try: - logger.debug("hidden debug context") - logger.info("visible info context") - finally: - logger.remove(sink_id) - - log_path = tmp_path / "pxmodrim" / "logs" / "pxmodrim.log" - levels = [ - json.loads(line)["record"]["level"]["name"] - for line in log_path.read_text(encoding="utf-8").splitlines() - ] - assert levels == ["INFO"] - - -def test_file_sink_includes_debug_when_requested(tmp_path: Path, monkeypatch) -> None: - event = _write_record(tmp_path, monkeypatch, "DEBUG", "debug context") - - assert event["record"]["level"]["name"] == "DEBUG" diff --git a/tests/test_metadata/__init__.py b/tests/test_metadata/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_metadata/test_metadata_cache.py b/tests/test_metadata/test_metadata_cache.py deleted file mode 100644 index 68987e2..0000000 --- a/tests/test_metadata/test_metadata_cache.py +++ /dev/null @@ -1,391 +0,0 @@ -from __future__ import annotations - -import os -from pathlib import Path -from unittest.mock import patch - -import aiosqlite -import pytest - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.providers import base as base_module -from pxmodrim.core.providers.local import DownloadedModProvider, LocalModProvider -from pxmodrim.core.services.metadata_cache import ( - MetadataCache, - normalize_path, -) - -ABOUT_XML_V1 = """ - - Sample Mod One - TestAuthor - Author.SampleModOne - -
  • 1.4
  • -
  • 1.5
  • -
    - Initial description - 1.0.0 - https://example.com/mod - UI/Icon.png - -
  • - ludeon.rimworld - RimWorld -
  • -
    -
    -""" - -ABOUT_XML_V2 = """ - - Sample Mod One Updated - TestAuthor - Author.SampleModOne - -
  • 1.4
  • -
  • 1.5
  • -
    - Updated description -
    -""" - -ABOUT_XML_TWO = """ - - Sample Mod Two - OtherAuthor - Author.SampleModTwo - -
  • 1.5
  • -
    - Second mod description -
    -""" - - -def _create_mod(mod_dir: Path, xml_content: str, pfid: str | None = None) -> Path: - about_dir = mod_dir / "About" - about_dir.mkdir(parents=True, exist_ok=True) - xml_path = about_dir / "About.xml" - xml_path.write_text(xml_content, encoding="utf-8") - if pfid is not None: - (about_dir / "PublishedFileId.txt").write_text(pfid, encoding="utf-8") - return mod_dir - - -@pytest.mark.asyncio -async def test_warm_cache_avoids_reparse(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - provider = LocalModProvider(mods_root, metadata_cache=cache) - - real_create_mod = base_module.create_listed_mod_from_path - parse_call_count = 0 - - def counting_create_mod(*args, **kwargs): - nonlocal parse_call_count - parse_call_count += 1 - return real_create_mod(*args, **kwargs) - - try: - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - discovered1 = await provider.discover("1.5") - - assert len(discovered1) == 1 - mod1 = discovered1[str(mod_a)] - assert isinstance(mod1, AboutXmlMod) - assert mod1.name == "Sample Mod One" - assert str(mod1.package_id) == "author.samplemodone" - assert mod1.provider_id == "local" - assert parse_call_count == 1 - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - discovered2 = await provider.discover("1.5") - assert len(discovered2) == 1 - mod2 = discovered2[str(mod_a)] - assert isinstance(mod2, AboutXmlMod) - assert mod2.name == "Sample Mod One" - assert str(mod2.package_id) == "author.samplemodone" - assert mod2.provider_id == "local" - assert mod2.mtime == mod1.mtime - assert mod2.authors == ["TestAuthor"] - assert mod2.supported_versions == {"1.4", "1.5"} - assert mod2.description == "Initial description" - assert mod2.mod_version == "1.0.0" - assert mod2.url == "https://example.com/mod" - assert mod2.mod_icon_path == Path("UI/Icon.png") - assert "ludeon.rimworld" in mod2.about_rules.dependencies - assert parse_call_count == 1 - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_force_reparse_bypasses_cache_and_refreshes_mod(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - cache = MetadataCache(tmp_path / "metadata-cache.db") - provider = LocalModProvider(mods_root, metadata_cache=cache) - real_create_mod = base_module.create_listed_mod_from_path - parse_call_count = 0 - - def counting_create_mod(*args, **kwargs): - nonlocal parse_call_count - parse_call_count += 1 - _, mod = real_create_mod(*args, **kwargs) - if parse_call_count == 2: - mod.name = "Fresh parse result" - return _, mod - - try: - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - first = await provider.discover("1.5") - assert first[str(mod_a)].name == "Sample Mod One" - assert parse_call_count == 1 - - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - cached = await provider.discover("1.5") - assert cached[str(mod_a)].name == "Sample Mod One" - assert parse_call_count == 1 - - with patch.object( - base_module, "create_listed_mod_from_path", side_effect=counting_create_mod - ): - reparsed = await provider.discover("1.5", force_reparse=True) - assert reparsed[str(mod_a)].name == "Fresh parse result" - assert parse_call_count == 2 - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_changed_metadata_reparses(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - provider = LocalModProvider(mods_root, metadata_cache=cache) - - try: - discovered1 = await provider.discover("1.5") - mod1 = discovered1[str(mod_a)] - assert mod1.name == "Sample Mod One" - - # Modify About.xml and bump mtime - xml_path = mod_a / "About" / "About.xml" - xml_path.write_text(ABOUT_XML_V2, encoding="utf-8") - new_mtime = xml_path.stat().st_mtime + 5.0 - os.utime(xml_path, (new_mtime, new_mtime)) - - discovered2 = await provider.discover("1.5") - mod2 = discovered2[str(mod_a)] - assert isinstance(mod2, AboutXmlMod) - assert mod2.name == "Sample Mod One Updated" - assert mod2.description == "Updated description" - assert mod2.mtime == pytest.approx(new_mtime, abs=1e-3) - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_deleted_mods_are_invalidated(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - mod_b = _create_mod(mods_root / "ModB", ABOUT_XML_TWO) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - - cfg = AppConfig() - cfg.paths.local = str(mods_root) - ctx = CoreContext(cfg) - - provider = LocalModProvider(mods_root, metadata_cache=cache) - mod_service = ModService(ctx, [provider], metadata_cache=cache) - - try: - await mod_service.discover() - assert len(ctx.all_mods) == 2 - assert str(mod_a) in ctx.all_mods - assert str(mod_b) in ctx.all_mods - - # Verify DB has entries for both mods - async with ( - aiosqlite.connect(str(db_path)) as conn, - conn.execute("SELECT mod_path FROM mod_metadata") as cur, - ): - rows = await cur.fetchall() - cached_paths = {r[0] for r in rows} - assert normalize_path(mod_a) in cached_paths - assert normalize_path(mod_b) in cached_paths - - # Delete ModB from disk - for p in (mod_b / "About").iterdir(): - p.unlink() - (mod_b / "About").rmdir() - mod_b.rmdir() - - # Run discover again - await mod_service.discover() - assert len(ctx.all_mods) == 1 - assert str(mod_a) in ctx.all_mods - assert str(mod_b) not in ctx.all_mods - - # Verify ModB was pruned from the database! - async with ( - aiosqlite.connect(str(db_path)) as conn, - conn.execute("SELECT mod_path FROM mod_metadata") as cur, - ): - rows_after = await cur.fetchall() - cached_paths_after = {r[0] for r in rows_after} - assert normalize_path(mod_a) in cached_paths_after - assert normalize_path(mod_b) not in cached_paths_after - finally: - await mod_service.close() - - -@pytest.mark.asyncio -async def test_corrupt_cache_recovers_safely(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_a = _create_mod(mods_root / "ModA", ABOUT_XML_V1) - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - - try: - # Populate cache - provider = LocalModProvider(mods_root, metadata_cache=cache) - discovered1 = await provider.discover("1.5") - assert len(discovered1) == 1 - - # Close cache and corrupt the file - await cache.close() - db_path.write_bytes(b"corrupt non-sqlite data" * 50) - - # New cache instance pointing to corrupted file - new_cache = MetadataCache(db_path) - new_provider = LocalModProvider(mods_root, metadata_cache=new_cache) - - # Discovery must not crash; it recovers and parses mods - discovered2 = await new_provider.discover("1.5") - assert len(discovered2) == 1 - mod = discovered2[str(mod_a)] - assert mod.name == "Sample Mod One" - discovered3 = await new_provider.discover("1.5") - assert len(discovered3) == 1 - assert discovered3[str(mod_a)].name == "Sample Mod One" - await new_cache.close() - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_provider_distinctions_preserved(tmp_path: Path) -> None: - local_root = tmp_path / "local" - steam_root = tmp_path / "downloaded" - - local_mod = _create_mod(local_root / "LocalMod", ABOUT_XML_V1) - steam_mod = _create_mod(steam_root / "SteamMod", ABOUT_XML_TWO, pfid="123456") - - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - - local_prov = LocalModProvider(local_root, metadata_cache=cache) - steam_prov = DownloadedModProvider(steam_root, metadata_cache=cache) - - try: - # Initial cold discovery - local_mods = await local_prov.discover("1.5") - steam_mods = await steam_prov.discover("1.5") - - assert len(local_mods) == 1 - assert len(steam_mods) == 1 - assert local_mods[str(local_mod)].provider_id == "local" - assert steam_mods[str(steam_mod)].provider_id == "downloaded" - - # Second warm discovery - local_mods_warm = await local_prov.discover("1.5") - steam_mods_warm = await steam_prov.discover("1.5") - - assert len(local_mods_warm) == 1 - assert len(steam_mods_warm) == 1 - assert local_mods_warm[str(local_mod)].provider_id == "local" - assert steam_mods_warm[str(steam_mod)].provider_id == "downloaded" - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_target_version_change_reparses(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - mod_dir = mods_root / "ModVersioned" - about_xml = """ - - Versioned Mod - Author - Author.Versioned - - Description for 1.4 - Description for 1.5 - - -""" - _create_mod(mod_dir, about_xml) - db_path = tmp_path / "metadata-cache.db" - cache = MetadataCache(db_path) - provider = LocalModProvider(mods_root, metadata_cache=cache) - - try: - d14 = await provider.discover("1.4") - assert d14[str(mod_dir)].description == "Description for 1.4" - - # Discovering for 1.5 misses cache because target_version differs - d15 = await provider.discover("1.5") - assert d15[str(mod_dir)].description == "Description for 1.5" - finally: - await cache.close() - - -@pytest.mark.asyncio -async def test_minimal_context_does_not_use_user_cache(tmp_path: Path) -> None: - mods_root = tmp_path / "mods" - _create_mod(mods_root / "ModA", ABOUT_XML_V1) - cfg = AppConfig() - ctx = CoreContext(cfg) - assert not ctx.has_config_service - provider = LocalModProvider(mods_root) - mod_service = ModService(ctx, [provider]) - assert mod_service._metadata_cache is None - - -def test_normalize_paths_matches_normalize_path(tmp_path: Path) -> None: - from pxmodrim.core.services.metadata_cache import normalize_path, normalize_paths - - real = tmp_path / "real" - (real / "mod").mkdir(parents=True) - (tmp_path / "other").mkdir() - (tmp_path / "linkdir").symlink_to(real) - (real / "linkmod").symlink_to(tmp_path / "other") - paths = [ - real / "mod", - tmp_path / "linkdir" / "mod", - tmp_path / "linkdir" / "linkmod", - tmp_path / "linkdir" / ".." / "real" / "mod", - tmp_path / "missing" / "x", - Path("relative/mod"), - ] - assert normalize_paths(paths) == [normalize_path(p) for p in paths] diff --git a/tests/test_metadata/test_mods_config.py b/tests/test_metadata/test_mods_config.py deleted file mode 100644 index 8a8e42e..0000000 --- a/tests/test_metadata/test_mods_config.py +++ /dev/null @@ -1,480 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core import mods_config as mods_config_module -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr, ModsConfig -from pxmodrim.core.mods_config import ( - create_snapshot, - get_snapshots, - parse_mods_config, - restore_snapshot, - write_mods_config, -) - -SAMPLE_XML = """ - - 1.6.4871 rev600 - -
  • ludeon.rimworld
  • -
  • ludeon.rimworld.royalty
  • -
  • ludeon.rimworld.ideology
  • -
  • ludeon.rimworld.biotech
  • -
  • ludeon.rimworld.anomaly
  • -
  • ludeon.rimworld.odyssey
  • -
    - -
  • ludeon.rimworld.royalty
  • -
  • ludeon.rimworld.ideology
  • -
  • ludeon.rimworld.biotech
  • -
  • ludeon.rimworld.anomaly
  • -
  • ludeon.rimworld.odyssey
  • -
    -
    -""" - - -def _parse_str(content: str, tmp_path: Path) -> ModsConfig | None: - path = tmp_path / "ModsConfig.xml" - path.write_text(content, encoding="utf-8") - return parse_mods_config(path) - - -class TestParseModsConfig: - def test_parse_full(self, tmp_path: Path) -> None: - config = _parse_str(SAMPLE_XML, tmp_path) - assert config is not None - assert config.version == "1.6.4871 rev600" - assert config.activeMods == [ - CaseInsensitiveStr("ludeon.rimworld"), - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - CaseInsensitiveStr("ludeon.rimworld.odyssey"), - ] - assert config.knownExpansions == [ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - CaseInsensitiveStr("ludeon.rimworld.odyssey"), - ] - - def test_parse_single_active_mod(self, tmp_path: Path) -> None: - xml = """ - - 1.5 - -
  • ludeon.rimworld
  • -
    - -
  • ludeon.rimworld.royalty
  • -
    -
    """ - config = _parse_str(xml, tmp_path) - assert config is not None - assert config.activeMods == [CaseInsensitiveStr("ludeon.rimworld")] - assert config.knownExpansions == [CaseInsensitiveStr("ludeon.rimworld.royalty")] - - def test_parse_empty_config(self, tmp_path: Path) -> None: - xml = """ - - 1.5 - -""" - config = _parse_str(xml, tmp_path) - assert config is not None - assert config.activeMods == [] - assert len(config.knownExpansions) > 0 - - def test_parse_missing_file(self) -> None: - result = parse_mods_config(Path("/nonexistent/ModsConfig.xml")) - assert result is None - - -class TestModsConfigToDict: - def test_to_dict_structure_with_li(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - d = config.to_dict() - assert d == { - "version": "1.5", - "activeMods": {"li": ["ludeon.rimworld"]}, - "knownExpansions": {"li": ["ludeon.rimworld.royalty"]}, - } - - def test_to_dict_empty_lists(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[], - ) - d = config.to_dict() - assert d["activeMods"] == {"li": []} - assert d["knownExpansions"] == {"li": []} - - -class TestModsConfigRoundTrip: - def test_round_trip(self, tmp_path: Path) -> None: - original = ModsConfig( - version="1.6.4871 rev600", - activeMods=[ - CaseInsensitiveStr("ludeon.rimworld"), - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - ], - knownExpansions=[ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - CaseInsensitiveStr("ludeon.rimworld.biotech"), - CaseInsensitiveStr("ludeon.rimworld.anomaly"), - ], - ) - path = tmp_path / "ModsConfig.xml" - write_mods_config(path, original, tmp_path / "snapshots") - - loaded = parse_mods_config(path) - assert loaded is not None - assert loaded.version == original.version - assert loaded.activeMods == original.activeMods - assert loaded.knownExpansions == original.knownExpansions - - def test_round_trip_preserves_unknown_xml(self, tmp_path: Path) -> None: - path = tmp_path / "ModsConfig.xml" - path.write_text( - SAMPLE_XML.replace( - "", - '' - '42', - ), - encoding="utf-8", - ) - loaded = parse_mods_config(path) - assert loaded is not None - loaded.activeMods = [CaseInsensitiveStr("author.updated")] - - write_mods_config(path, loaded, tmp_path / "snapshots") - - root = mods_config_module.ET.parse(str(path)).getroot() - assert root.get("custom") == "preserve" - assert root.findtext("./otherSetting/value") == "42" - assert root.find("./otherSetting").get("enabled") == "true" - assert root.findtext("./activeMods/li") == "author.updated" - - -class TestModsConfigProperties: - def test_active_mods_getter_returns_copy(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[], - ) - mods = config.activeMods - mods.append(CaseInsensitiveStr("test.extra")) - assert config.activeMods == [CaseInsensitiveStr("ludeon.rimworld")] - - def test_known_expansions_getter_returns_copy(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - exps = config.knownExpansions - exps.clear() - assert config.knownExpansions == [CaseInsensitiveStr("ludeon.rimworld.royalty")] - - def test_setter_normalizes_case(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[], - ) - config.activeMods = ["Test.UPPERCASE.Mod"] - assert config.activeMods == [CaseInsensitiveStr("test.uppercase.mod")] - - def test_clear_active_mods(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - config.clear_active_mods() - assert config.activeMods == [] - assert config.knownExpansions == [CaseInsensitiveStr("ludeon.rimworld.royalty")] - - def test_clear_all(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("ludeon.rimworld")], - knownExpansions=[CaseInsensitiveStr("ludeon.rimworld.royalty")], - ) - config.clear_all() - assert config.activeMods == [] - assert config.knownExpansions == [] - - def test_check_active_duplicates_true(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[ - CaseInsensitiveStr("mod.a"), - CaseInsensitiveStr("Mod.A"), - ], - knownExpansions=[], - ) - assert config.check_active_duplicates() is True - - def test_check_active_duplicates_false(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[ - CaseInsensitiveStr("mod.a"), - CaseInsensitiveStr("mod.b"), - ], - knownExpansions=[], - ) - assert config.check_active_duplicates() is False - - def test_check_expansions_duplicates_true(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("Ludeon.RimWorld.Royalty"), - ], - ) - assert config.check_expansions_duplicates() is True - - def test_check_expansions_duplicates_false(self) -> None: - config = ModsConfig( - version="1.5", - activeMods=[], - knownExpansions=[ - CaseInsensitiveStr("ludeon.rimworld.royalty"), - CaseInsensitiveStr("ludeon.rimworld.ideology"), - ], - ) - assert config.check_expansions_duplicates() is False - - -def _mods_config(package_id: str) -> ModsConfig: - return ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr(package_id)], - knownExpansions=[], - ) - - -class TestModListSnapshotAndRollback: - def test_replacing_config_preserves_previous_contents(self, tmp_path: Path) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - - write_mods_config(config_path, _mods_config("mod.alpha"), snapshots_dir) - assert get_snapshots(snapshots_dir) == [] - - write_mods_config(config_path, _mods_config("mod.beta"), snapshots_dir) - - [snapshot] = get_snapshots(snapshots_dir) - previous = parse_mods_config(snapshot) - current = parse_mods_config(config_path) - assert previous is not None - assert previous.activeMods == [CaseInsensitiveStr("mod.alpha")] - assert current is not None - assert current.activeMods == [CaseInsensitiveStr("mod.beta")] - - def test_replacement_retains_only_newest_ten_snapshots( - self, tmp_path: Path - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.0"), snapshots_dir) - - for index in range(1, 12): - write_mods_config( - config_path, - _mods_config(f"mod.{index}"), - snapshots_dir, - ) - - snapshots = get_snapshots(snapshots_dir) - retained_ids = [] - for snapshot in snapshots: - parsed = parse_mods_config(snapshot) - assert parsed is not None - retained_ids.append(parsed.activeMods[0]) - - assert len(snapshots) == 10 - assert retained_ids == [ - CaseInsensitiveStr(f"mod.{index}") for index in range(10, 0, -1) - ] - - def test_configurable_retention_limit_honored(self, tmp_path: Path) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config( - config_path, _mods_config("mod.0"), snapshots_dir, max_snapshots=3 - ) - - for index in range(1, 6): - write_mods_config( - config_path, - _mods_config(f"mod.{index}"), - snapshots_dir, - max_snapshots=3, - ) - - snapshots = get_snapshots(snapshots_dir) - assert len(snapshots) == 3 - retained_ids = [ - parsed.activeMods[0] - for snapshot in snapshots - if (parsed := parse_mods_config(snapshot)) is not None - ] - assert retained_ids == [ - CaseInsensitiveStr("mod.4"), - CaseInsensitiveStr("mod.3"), - CaseInsensitiveStr("mod.2"), - ] - - # Lowering limit on duplicate content prunes excess snapshots before returning - config_path.write_bytes(snapshots[0].read_bytes()) - duplicate = create_snapshot(config_path, snapshots_dir, max_snapshots=2) - assert duplicate == snapshots[0] - assert len(get_snapshots(snapshots_dir)) == 2 - - def test_restoring_oldest_at_full_retention_preserves_snapshot_and_backup( - self, tmp_path: Path - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - max_snapshots = 3 - write_mods_config( - config_path, - _mods_config("mod.0"), - snapshots_dir, - max_snapshots=max_snapshots, - ) - for index in range(1, 4): - write_mods_config( - config_path, - _mods_config(f"mod.{index}"), - snapshots_dir, - max_snapshots=max_snapshots, - ) - - selected_snapshot = get_snapshots(snapshots_dir)[-1] - selected_data = selected_snapshot.read_bytes() - - assert ( - restore_snapshot( - selected_snapshot, - config_path, - snapshots_dir, - max_snapshots=max_snapshots, - ) - is True - ) - - restored = parse_mods_config(config_path) - assert restored is not None - assert restored.activeMods == [CaseInsensitiveStr("mod.0")] - assert selected_snapshot.exists() - assert selected_snapshot.read_bytes() == selected_data - - snapshots = get_snapshots(snapshots_dir) - retained_ids = [] - for snapshot in snapshots: - parsed = parse_mods_config(snapshot) - assert parsed is not None - retained_ids.append(parsed.activeMods[0]) - - assert len(snapshots) == max_snapshots - assert retained_ids == [ - CaseInsensitiveStr("mod.3"), - CaseInsensitiveStr("mod.2"), - CaseInsensitiveStr("mod.0"), - ] - - def test_restore_preserves_exact_snapshot_and_backs_up_current( - self, tmp_path: Path - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.current"), snapshots_dir) - - snapshots_dir.mkdir(parents=True) - snapshot_path = snapshots_dir / "ModsConfig_20260922_100000.xml" - snapshot_data = ( - b"\n" - b"1.5" - b"
  • mod.saved
  • \n" - ) - snapshot_path.write_bytes(snapshot_data) - - assert restore_snapshot(snapshot_path, config_path, snapshots_dir) is True - assert config_path.read_bytes() == snapshot_data - - backups = [ - path for path in get_snapshots(snapshots_dir) if path != snapshot_path - ] - assert len(backups) == 1 - backup = parse_mods_config(backups[0]) - assert backup is not None - assert backup.activeMods == [CaseInsensitiveStr("mod.current")] - - def test_invalid_snapshot_never_changes_current_file(self, tmp_path: Path) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.safe"), snapshots_dir) - original = config_path.read_bytes() - - invalid_snapshots = [ - snapshots_dir / "ModsConfig_missing.xml", - snapshots_dir / "ModsConfig_corrupt.xml", - snapshots_dir / "ModsConfig_wrong_root.xml", - snapshots_dir / "ModsConfig_missing_active_mods.xml", - ] - snapshots_dir.mkdir(parents=True, exist_ok=True) - invalid_snapshots[1].write_text("not XML", encoding="utf-8") - invalid_snapshots[2].write_text("", encoding="utf-8") - invalid_snapshots[3].write_text( - "1.5", - encoding="utf-8", - ) - - for snapshot in invalid_snapshots: - before = set(get_snapshots(snapshots_dir)) - assert restore_snapshot(snapshot, config_path, snapshots_dir) is False - assert config_path.read_bytes() == original - assert set(get_snapshots(snapshots_dir)) == before - - def test_failed_atomic_replace_leaves_current_file_intact( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - snapshots_dir = tmp_path / "snapshots" - config_path = tmp_path / "ModsConfig.xml" - write_mods_config(config_path, _mods_config("mod.safe"), snapshots_dir) - original = config_path.read_bytes() - real_replace = mods_config_module.os.replace - - def fail_target_replace(source: Path, target: Path) -> None: - if Path(target) == config_path: - raise OSError("replace failed") - real_replace(source, target) - - monkeypatch.setattr(mods_config_module.os, "replace", fail_target_replace) - - with pytest.raises(OSError, match="replace failed"): - write_mods_config(config_path, _mods_config("mod.new"), snapshots_dir) - - assert config_path.read_bytes() == original - assert not list(tmp_path.glob(".*.tmp")) diff --git a/tests/test_metadata/test_parsing.py b/tests/test_metadata/test_parsing.py deleted file mode 100644 index f31c13b..0000000 --- a/tests/test_metadata/test_parsing.py +++ /dev/null @@ -1,178 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import lxml.etree as ET -import pytest - -from pxmodrim.core.models.metadata.parsing import ( - _match_byversion_raw, - _match_versioned_child, - create_about_mod, - create_listed_mod_from_path, - match_version, - value_extractor, -) -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.utils import find_about_xml - -TEST_DATA = Path(__file__).parent / "data" - - -class TestValueExtractor: - def test_nested_dict(self) -> None: - assert value_extractor({"key": "value"}) == "value" - - def test_ignore_if_no_matching_field(self) -> None: - result = value_extractor( - {"@IgnoreIfNoMatchingField": "True", "#text": "actual"} - ) - assert result == "actual" - - def test_none(self) -> None: - assert value_extractor(None) is None - - -class TestCreateAboutMod: - def test_basic_mod(self) -> None: - data = { - "name": "Test Mod", - "author": "Tester", - "packageId": "test.mod", - "description": "A test mod.", - "supportedVersions": {"li": "1.5"}, - } - valid, mod = create_about_mod(data, "1.5") - assert valid is True - assert mod.name == "Test Mod" - assert mod.authors == ["Tester"] - assert str(mod.package_id) == "test.mod" - assert mod.supported_versions == {"1.5"} - - def test_missing_package_id_is_invalid(self) -> None: - valid, mod = create_about_mod({"name": "No ID Mod"}, "1.5") - assert valid is False - assert mod.valid is False - assert str(mod.package_id) == "missing.packageid" - - def test_empty_package_id_is_invalid(self) -> None: - valid, mod = create_about_mod({"packageId": ""}, "1.5") - assert valid is False - assert mod.valid is False - - def test_version_keys_must_match_exactly(self) -> None: - assert match_version({"v1.50": "wrong", "v1x5-beta": "wrong"}, "1.5") == ( - False, - None, - ) - assert _match_byversion_raw( - {"v1.50": "wrong", "v1x5-beta": "wrong"}, "1.5" - ) == (False, None) - parent = ET.Element("versions") - parent.extend([ET.Element("v1.50"), ET.Element("v1x5-beta")]) - assert _match_versioned_child(parent, "1.5") is None - - def test_unprefixed_exact_version_key_is_supported(self) -> None: - assert match_version({"1.5": "right"}, "1.5") == (True, "right") - - def test_missing_package_id_in_xml_is_invalid(self, tmp_path: Path) -> None: - about_dir = tmp_path / "About" - about_dir.mkdir() - about_path = about_dir / "About.xml" - about_path.write_text( - "No ID Mod", encoding="utf-8" - ) - valid, mod = create_listed_mod_from_path( - tmp_path, "1.5", about_xml_path=about_path - ) - assert valid is False - assert isinstance(mod, AboutXmlMod) - assert mod.valid is False - assert str(mod.package_id) == "missing.packageid" - - def test_dlc_mod(self) -> None: - data = {"packageId": "ludeon.rimworld.royalty"} - valid, mod = create_about_mod(data, "1.5") - assert valid is True - assert mod.name == "Royalty" - assert mod.steam_app_id == 1149640 - - @pytest.mark.parametrize( - ("package_id", "expected_name"), - [ - ("Ludeon.RimWorld", "Core"), - ("Ludeon.RimWorld.Royalty", "Royalty"), - ("Ludeon.RimWorld.Ideology", "Ideology"), - ("Ludeon.RimWorld.Biotech", "Biotech"), - ("Ludeon.RimWorld.Anomaly", "Anomaly"), - ("Ludeon.RimWorld.Odyssey", "Odyssey"), - ], - ) - def test_official_mod_without_name_uses_package_id_mapping( - self, tmp_path: Path, package_id: str, expected_name: str - ) -> None: - about_dir = tmp_path / "About" - about_dir.mkdir() - (about_dir / "About.xml").write_text( - f"{package_id}" - "Ludeon Studios", - encoding="utf-8", - ) - - valid, mod = create_listed_mod_from_path(tmp_path, "1.6") - - assert valid - assert mod.name == expected_name - - def test_explicit_name_wins_for_official_mod(self) -> None: - valid, mod = create_about_mod( - {"packageId": "ludeon.rimworld.royalty", "name": "Custom official name"}, - "1.6", - ) - - assert valid - assert mod.name == "Custom official name" - - -class TestFindAboutXml: - def test_valid_mod(self) -> None: - about_path = find_about_xml(TEST_DATA / "valid_mod") - assert about_path is not None - assert about_path.name == "About.xml" - - def test_nonexistent(self) -> None: - result = find_about_xml(TEST_DATA / "nonexistent") - assert result is None - - -class TestCreateListedModFromPath: - def test_valid_mod(self) -> None: - mod_path = TEST_DATA / "valid_mod" - valid, mod = create_listed_mod_from_path(mod_path, "1.5") - assert valid is True - assert isinstance(mod, AboutXmlMod) - assert mod.name == "Test Mod" - assert mod.authors == ["TestAuthor"] - assert str(mod.package_id) == "test.mod" - assert mod.mod_path == mod_path - assert mod.provider_id == "" - - def test_invalid_path(self) -> None: - with pytest.raises(ValueError, match="Path must be a directory"): - create_listed_mod_from_path( - TEST_DATA / "valid_mod" / "About" / "About.xml", - "1.5", - ) - - def test_malformed_about_xml(self, tmp_path: Path) -> None: - about_dir = tmp_path / "About" - about_dir.mkdir() - (about_dir / "About.xml").write_text("", encoding="utf-8") - - valid, mod = create_listed_mod_from_path(tmp_path, "1.5") - - assert valid is False - assert isinstance(mod, AboutXmlMod) - assert mod.valid is False - assert mod.mod_path == tmp_path - assert mod.uuid == str(tmp_path) diff --git a/tests/test_metadata/test_sidebar.py b/tests/test_metadata/test_sidebar.py deleted file mode 100644 index baca75a..0000000 --- a/tests/test_metadata/test_sidebar.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.models.view.sidebar import AllModsEntry, ProviderModsEntry - - -def test_sidebar_entries_own_their_visible_uuids() -> None: - first = AllModsEntry() - second = AllModsEntry() - - first.visible_uuids.add("first") - - assert second.visible_uuids == set() - provider = ProviderModsEntry("local", "Local", {"provider"}) - assert provider.visible_uuids == {"provider"} - assert provider.count == 1 diff --git a/tests/test_metadata/test_startup_impact.py b/tests/test_metadata/test_startup_impact.py deleted file mode 100644 index e7a4144..0000000 --- a/tests/test_metadata/test_startup_impact.py +++ /dev/null @@ -1,247 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core.services.startup_impact_service import ( - StartupImpactReport, - format_impact, - get_startup_impact_path, - normalize_package_id, - parse_startup_impact, -) - - -def _write_json(path: Path, content: str) -> Path: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content, encoding="utf-8") - return path - - -_SAMPLE_JSON = """{ - "timestamp": "2026-07-16T14:30:45.123Z", - "loadingTime": 42500.5, - "metrics": { - "LoadModXml": 10200.0, - "ResolveReferences": 1500.0 - }, - "totalImpact": 0.0, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0, - "mods": [ - { - "modName": "Harmony", - "modPackageId": "brrainz.harmony", - "metrics": { - "ModConstructor": 1000.0 - }, - "totalImpact": 1230.5, - "offThreadMetrics": { - "LoadPatches": 300.0 - }, - "offThreadTotalImpact": 300.0 - }, - { - "modName": "Some Mod", - "modPackageId": "author.somemod", - "metrics": { - "LoadModXml": 4500.0 - }, - "totalImpact": 4500.0, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0 - }, - { - "modName": "Fast Mod", - "modPackageId": "author.fast", - "metrics": {}, - "totalImpact": 50.0, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0 - }, - { - "modName": "No Package ID Mod", - "totalImpact": 200.0, - "metrics": {}, - "offThreadMetrics": {}, - "offThreadTotalImpact": 0.0 - } - ] -} -""" - - -class TestNormalizePackageId: - def test_lowercase(self) -> None: - assert normalize_package_id("Author.Mod") == "author.mod" - - def test_steam_suffix(self) -> None: - assert normalize_package_id("author.mod_steam") == "author.mod" - - def test_already_normalized(self) -> None: - assert normalize_package_id("author.mod") == "author.mod" - - -class TestFormatImpact: - def test_seconds_to_ms(self) -> None: - assert format_impact(1.234) == "1234ms" - - def test_fractional(self) -> None: - assert format_impact(0.05) == "50ms" - - def test_zero(self) -> None: - assert format_impact(0.0) == "0ms" - - -class TestGetStartupImpactPath: - def test_derives_from_config_parent(self) -> None: - result = get_startup_impact_path(Path("/home/user/Config")) - assert result == Path("/home/user/StartupImpactData.json") - - def test_accepts_str(self) -> None: - result = get_startup_impact_path("/home/user/Config") - assert result == Path("/home/user/StartupImpactData.json") - - -class TestParseStartupImpact: - def test_parse_full_report(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - assert isinstance(report, StartupImpactReport) - assert report.loading_time_s == 42.5005 - assert report.timestamp == "2026-07-16T14:30:45.123Z" - assert report.metrics == {"LoadModXml": 10.2, "ResolveReferences": 1.5} - assert report.total_impact == 0.0 - assert len(report.mods) == 4 - - def test_base_game_time_from_metrics(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - # Base game time is the sum of the base-game profiler metrics, not a - # subtraction of loading time minus mod impacts. - assert report.base_game_time == pytest.approx(11.7) - # loading_time is the true wall-clock total and is NOT equal to the - # sum of mod on-thread impacts (there is a real unaccounted "Others"). - assert report.loading_time_s != pytest.approx(report.total_time) - - def test_mods_total_includes_off_thread(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - harmony = report.mods[0] - assert harmony.total_impact_s == 1.2305 - assert harmony.off_thread_total_impact_s == 0.3 - # total_time counts only on-thread mod impact; mods_total_time adds - # off-thread impact. - assert report.total_time == pytest.approx(1.2305 + 4.5 + 0.05 + 0.2) - assert report.off_thread_total_time == pytest.approx(0.3) - assert report.mods_total_time == pytest.approx(report.total_time + 0.3) - - def test_mod_values(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - - harmony = report.mods[0] - assert harmony.mod_name == "Harmony" - assert harmony.package_id == "brrainz.harmony" - assert harmony.total_impact_s == 1.2305 - assert harmony.metrics == {"ModConstructor": 1.0} - - some_mod = report.mods[1] - assert some_mod.mod_name == "Some Mod" - assert some_mod.package_id == "author.somemod" - assert some_mod.total_impact_s == 4.5 - assert some_mod.metrics == {"LoadModXml": 4.5} - - fast = report.mods[2] - assert fast.mod_name == "Fast Mod" - assert fast.package_id == "author.fast" - assert fast.total_impact_s == 0.05 - assert fast.metrics == {} - - def test_missing_package_id(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "StartupImpactData.json", _SAMPLE_JSON) - report = parse_startup_impact(path) - assert report is not None - - no_pid = report.mods[3] - assert no_pid.mod_name == "No Package ID Mod" - assert no_pid.package_id is None - assert no_pid.total_impact_s == 0.2 - assert no_pid.metrics == {} - - def test_file_not_found(self, tmp_path: Path) -> None: - report = parse_startup_impact(tmp_path / "nonexistent.json") - assert report is None - - def test_empty_mods(self, tmp_path: Path) -> None: - data = """{"loadingTime": 1000.0, "mods": []}""" - path = _write_json(tmp_path / "empty_mods.json", data) - report = parse_startup_impact(path) - assert report is not None - assert report.loading_time_s == 1.0 - assert len(report.mods) == 0 - - def test_not_a_dict(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "not_a_dict.json", "[]") - report = parse_startup_impact(path) - assert report is None - - def test_malformed_json(self, tmp_path: Path) -> None: - path = _write_json(tmp_path / "bad.json", "{invalid") - report = parse_startup_impact(path) - assert report is None - - def test_invalid_json_structure(self, tmp_path: Path) -> None: - data = """{"wrongKey": "value"}""" - path = _write_json(tmp_path / "invalid.json", data) - report = parse_startup_impact(path) - assert report is not None - assert len(report.mods) == 0 - - def test_total_time(self, tmp_path: Path) -> None: - data = """{ - "loadingTime": 50000.0, - "mods": [ - {"modName": "A", "modPackageId": "a.a", - "totalImpact": 2000.0, "metrics": {}, - "offThreadMetrics": {}, "offThreadTotalImpact": 0.0}, - {"modName": "B", "modPackageId": "b.b", - "totalImpact": 3000.0, "metrics": {}, - "offThreadMetrics": {}, "offThreadTotalImpact": 0.0} - ] - }""" - r = parse_startup_impact(_write_json(tmp_path / "total.json", data)) - assert r is not None - assert r.total_time == pytest.approx(5.0) - - def test_total_time_empty(self, tmp_path: Path) -> None: - data = """{"loadingTime": 1000.0, "mods": []}""" - r = parse_startup_impact(_write_json(tmp_path / "total_empty.json", data)) - assert r is not None - assert r.total_time == 0.0 - - def test_mod_off_thread_fields(self, tmp_path: Path) -> None: - data = """{ - "loadingTime": 10000.0, - "mods": [ - { - "modName": "Test Mod", - "modPackageId": "author.test", - "totalImpact": 2000.0, - "metrics": {"A": 1000.0, "B": 1000.0}, - "offThreadMetrics": {"C": 500.0}, - "offThreadTotalImpact": 500.0 - } - ] - }""" - path = _write_json(tmp_path / "offthread.json", data) - report = parse_startup_impact(path) - assert report is not None - mod = report.mods[0] - assert mod.off_thread_metrics == {"C": 0.5} - assert mod.off_thread_total_impact_s == 0.5 diff --git a/tests/test_metadata/test_startup_impact_db.py b/tests/test_metadata/test_startup_impact_db.py deleted file mode 100644 index bcfe1b4..0000000 --- a/tests/test_metadata/test_startup_impact_db.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path - -import pytest - -from pxmodrim.core.services.startup_impact_service.db import StartupImpactDb -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, -) - - -@pytest.mark.asyncio -async def test_store_report_and_clear_are_serialized(tmp_path: Path) -> None: - db = StartupImpactDb() - path = tmp_path / "cache.db" - report = StartupImpactReport( - path="", - loading_time_s=1.0, - timestamp="now", - mods=(StartupImpactMod("Example", "author.example", 0.5),), - ) - try: - await asyncio.gather(db.store_report(path, report), db.clear(path)) - latest = await db.get_latest_report(path) - assert latest is None or latest.mods[0].package_id == "author.example" - await db.store_report(path, report) - latest = await db.get_latest_report(path) - assert latest is not None - assert latest.mods[0].package_id == "author.example" - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_base_game_keeps_sentinel_package_id_after_round_trip( - tmp_path: Path, -) -> None: - db = StartupImpactDb() - path = tmp_path / "cache.db" - report = StartupImpactReport( - path="", - loading_time_s=1.0, - timestamp="now", - mods=( - StartupImpactMod("Example", "author.example", 0.5), - StartupImpactMod(BASE_GAME_PID, BASE_GAME_PID, 2.0), - ), - ) - try: - await db.store_report(path, report) - latest = await db.get_latest_report(path) - finally: - await db.close() - assert latest is not None - by_name = {m.mod_name: m.package_id for m in latest.mods} - assert by_name[BASE_GAME_PID] == BASE_GAME_PID diff --git a/tests/test_metadata/test_startup_impact_phases.py b/tests/test_metadata/test_startup_impact_phases.py deleted file mode 100644 index 065b310..0000000 --- a/tests/test_metadata/test_startup_impact_phases.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.services.startup_impact_service.labels import metric_label -from pxmodrim.core.services.startup_impact_service.phases import ( - Phase, - metric_phase, - phase_totals, -) - -_P = "LoadingProgress.StartupImpact." - - -@pytest.mark.parametrize( - ("key", "phase"), - [ - (f"{_P}ModContentPackReloadContentInt.Textures", Phase.CONTENT), - (f"{_P}GlobalTextureAtlasManagerBakeStaticAtlases", Phase.CONTENT), - (f"{_P}ModConstructor", Phase.MOD_CONSTRUCTORS), - (f"{_P}StaticConstructorOnStartupUtilityCallAll", Phase.STATIC_CONSTRUCTORS), - (f"{_P}ApplyPatches", Phase.PATCHES), - (f"{_P}ErrorCheckPatches", Phase.PATCHES), - (f"{_P}LoadDefs", Phase.DEFS), - (f"{_P}ResolveAllWantedCrossReferences.NonImplied", Phase.DEFS), - ( - f"{_P}ExecuteToExecuteWhenFinished|HugsLib.Utils.HarmonyUtility", - Phase.DEFERRED, - ), - (f"{_P}LanguageDatabaseInitAllMetadata", Phase.OTHER), - ("SomethingNew", Phase.OTHER), - ], -) -def test_metric_phase(key: str, phase: Phase) -> None: - assert metric_phase(key) is phase - - -def test_phase_totals_sums_per_phase_and_zero_fills() -> None: - totals = phase_totals( - {f"{_P}ApplyPatches": 1.5, f"{_P}LoadPatches": 0.5, f"{_P}LoadDefs": 2.0} - ) - assert totals[Phase.PATCHES] == pytest.approx(2.0) - assert totals[Phase.DEFS] == pytest.approx(2.0) - assert totals[Phase.CONTENT] == 0.0 - assert set(totals) == set(Phase) - - -def test_metric_label_names_deferred_task_target() -> None: - key = ( - f"{_P}ExecuteToExecuteWhenFinished|RimWorld.AbilityDef" - " -> Void b__88_0()" - ) - expected = "Running delayed initialization task (RimWorld.AbilityDef)" - assert metric_label(key) == expected diff --git a/tests/test_metadata/test_structures.py b/tests/test_metadata/test_structures.py deleted file mode 100644 index 47e0826..0000000 --- a/tests/test_metadata/test_structures.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, - ListedMod, -) - - -class TestCaseInsensitiveStr: - def test_lowercase_storage(self) -> None: - pid = CaseInsensitiveStr("Test.Mod.PackageId") - assert str(pid) == "test.mod.packageid" - - def test_equality(self) -> None: - a = CaseInsensitiveStr("Test.Mod") - b = CaseInsensitiveStr("test.mod") - assert a == b - - def test_dict_key(self) -> None: - d: dict[CaseInsensitiveStr, str] = {} - d[CaseInsensitiveStr("Test.Mod")] = "value" - assert d[CaseInsensitiveStr("test.mod")] == "value" - - -class TestCaseInsensitiveSet: - def test_add_and_contains(self) -> None: - s = CaseInsensitiveSet() - s.add("Test.Mod") - assert "test.mod" in s - assert "Test.Mod" in s - - def test_discard(self) -> None: - s = CaseInsensitiveSet(["Test.Mod"]) - s.discard("test.mod") - assert len(s) == 0 - - def test_union(self) -> None: - a = CaseInsensitiveSet(["mod.a"]) - b = CaseInsensitiveSet(["Mod.B"]) - c = a | b - assert "mod.a" in c - assert "mod.b" in c - - def test_empty_eq(self) -> None: - assert CaseInsensitiveSet() == CaseInsensitiveSet() - - def test_unhashable(self) -> None: - with pytest.raises(TypeError): - hash(CaseInsensitiveSet()) - - -class TestListedMod: - def test_default_creation(self) -> None: - mod = ListedMod() - assert mod.name == "Unknown Mod Name" - assert mod.valid is True - assert mod.provider_id == "" - assert mod.mod_path is None - - def test_provider_id_assignable(self) -> None: - mod = ListedMod() - mod.provider_id = "local" - assert mod.provider_id == "local" - - def test_pathless_mod_uuids_are_unique(self) -> None: - assert ListedMod().uuid != ListedMod().uuid - - -class TestAboutXmlMod: - def test_default_creation(self) -> None: - mod = AboutXmlMod() - assert mod.name == "Unknown Mod Name" - assert mod.authors == [] - assert mod.mod_version == "" - assert isinstance(mod.package_id, CaseInsensitiveStr) - - def test_dlc_name(self) -> None: - mod = AboutXmlMod() - mod.package_id = CaseInsensitiveStr("ludeon.rimworld.royalty") - name = mod.get_dlc_name() - assert name == "RimWorld - Royalty" diff --git a/tests/test_organizer/__init__.py b/tests/test_organizer/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_organizer/test_db.py b/tests/test_organizer/test_db.py deleted file mode 100644 index 5a1fe1f..0000000 --- a/tests/test_organizer/test_db.py +++ /dev/null @@ -1,349 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import cast - -import pytest - -from pxmodrim.core.organizer.db import OrganizerDb -from pxmodrim.core.organizer.defaults import StandardRule -from pxmodrim.core.organizer.models import ( - ROOT_ID, - OrganizerError, - RuleField, - RuleSpec, -) - - -@pytest.fixture -def db(tmp_path: Path) -> OrganizerDb: - return OrganizerDb(tmp_path / "organizer.db") - - -@pytest.mark.asyncio -async def test_fresh_db_and_root_invariants(db: OrganizerDb) -> None: - try: - state = await db.load() - assert len(state.folders) == 1 - root = state.folders[ROOT_ID] - assert root.id == ROOT_ID - assert root.parent_id is None - assert root.name == "Root" - assert root.collapsed is False - assert state.placements == {} - assert state.tags == {} - assert state.mod_tags == {} - assert state.rules == () - - with pytest.raises(OrganizerError): - await db.delete_folder(ROOT_ID) - - with pytest.raises(OrganizerError): - await db.rename_folder(ROOT_ID, "CustomRoot") - - with pytest.raises(OrganizerError): - await db.set_parent(ROOT_ID, 2) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_duplicate_sibling_names_and_different_parents(db: OrganizerDb) -> None: - try: - f_a = await db.create_folder(ROOT_ID, "FolderA") - assert f_a.id > ROOT_ID - assert f_a.parent_id == ROOT_ID - assert f_a.name == "FolderA" - - # Case-insensitive duplicate sibling under root - with pytest.raises(OrganizerError): - await db.create_folder(ROOT_ID, "foldera") - - # Create another sibling - f_b = await db.create_folder(ROOT_ID, "FolderB") - - # Renaming sibling to duplicate existing sibling raises OrganizerError - with pytest.raises(OrganizerError): - await db.rename_folder(f_b.id, "foldera") - - # Same name under different parents is allowed - f_sub = await db.create_folder(f_a.id, "FolderB") - assert f_sub.parent_id == f_a.id - assert f_sub.name == "FolderB" - - # Moving f_sub to root should fail because root already has FolderB - with pytest.raises(OrganizerError): - await db.set_parent(f_sub.id, ROOT_ID) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_placement_upsert_and_unplace(db: OrganizerDb) -> None: - try: - f1 = await db.create_folder(ROOT_ID, "Folder1") - f2 = await db.create_folder(ROOT_ID, "Folder2") - - # Lowercases package IDs on place - await db.place(["Package.One", "PACKAGE.TWO"], f1.id) - st = await db.load() - assert st.placements["package.one"] == f1.id - assert st.placements["package.two"] == f1.id - - # Upsert: move Package.One to Folder2 - await db.place(["package.ONE"], f2.id) - st = await db.load() - assert st.placements["package.one"] == f2.id - assert st.placements["package.two"] == f1.id - - # Unplace removes rows - await db.unplace(["PACKAGE.ONE"]) - st = await db.load() - assert "package.one" not in st.placements - assert st.placements["package.two"] == f1.id - - # Placing into non-existent folder raises OrganizerError - with pytest.raises(OrganizerError): - await db.place(["package.three"], 9999) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_create_folder_with(db: OrganizerDb) -> None: - try: - f = await db.create_folder_with( - ROOT_ID, "CoreMods", ["Package.One", "package.two", "PACKAGE.THREE"] - ) - assert f.id > ROOT_ID - assert f.name == "CoreMods" - assert f.parent_id == ROOT_ID - - st = await db.load() - assert f.id in st.folders - assert st.placements["package.one"] == f.id - assert st.placements["package.two"] == f.id - assert st.placements["package.three"] == f.id - - with pytest.raises(OrganizerError): - await db.create_folder_with( - ROOT_ID, "coremods", ["package.four", "package.five"] - ) - - st2 = await db.load() - assert "package.four" not in st2.placements - assert "package.five" not in st2.placements - assert len(st2.folders) == len(st.folders) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_delete_folder_subtree_placements_and_descendants( - db: OrganizerDb, -) -> None: - try: - # Build hierarchy: Root -> f_parent -> f_child -> f_grandchild - f_parent = await db.create_folder(ROOT_ID, "Parent") - f_child = await db.create_folder(f_parent.id, "Child") - f_grandchild = await db.create_folder(f_child.id, "Grandchild") - f_other = await db.create_folder(ROOT_ID, "Other") - - await db.place(["mod.parent"], f_parent.id) - await db.place(["mod.child"], f_child.id) - await db.place(["mod.grandchild"], f_grandchild.id) - await db.place(["mod.other"], f_other.id) - await db.place(["mod.root"], ROOT_ID) - - # Deleting parent moves subtree placements to ROOT_ID and drops descendants - await db.delete_folder(f_parent.id) - - st = await db.load() - assert f_parent.id not in st.folders - assert f_child.id not in st.folders - assert f_grandchild.id not in st.folders - assert f_other.id in st.folders - - assert st.placements["mod.parent"] == ROOT_ID - assert st.placements["mod.child"] == ROOT_ID - assert st.placements["mod.grandchild"] == ROOT_ID - assert st.placements["mod.other"] == f_other.id - assert st.placements["mod.root"] == ROOT_ID - - # Deleting non-existent folder raises OrganizerError - with pytest.raises(OrganizerError): - await db.delete_folder(9999) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_tags_lifecycle_and_mod_tags(db: OrganizerDb) -> None: - try: - t1 = await db.create_tag("Tag1", "#ff0000") - t2 = await db.create_tag("Tag2", "#00ff00") - assert t1.name == "Tag1" - assert t1.color == "#ff0000" - - # Duplicate tag name raises OrganizerError - with pytest.raises(OrganizerError): - await db.create_tag("tag1", "#123456") - - # Set mod tags - await db.set_mod_tags(["Mod.Alpha", "Mod.Beta"], add=[t1.id, t2.id]) - st = await db.load() - assert st.mod_tags["mod.alpha"] == frozenset({t1.id, t2.id}) - assert st.mod_tags["mod.beta"] == frozenset({t1.id, t2.id}) - - # Remove a tag from Mod.Alpha - await db.set_mod_tags(["mod.alpha"], remove=[t1.id]) - st = await db.load() - assert st.mod_tags["mod.alpha"] == frozenset({t2.id}) - - # Invalid tag ID in add raises OrganizerError - with pytest.raises(OrganizerError): - await db.set_mod_tags(["mod.alpha"], add=[9999]) - - # Update tag - await db.update_tag(t1.id, "Tag1Updated", "#aaaaaa") - st = await db.load() - assert st.tags[t1.id].name == "Tag1Updated" - - # Update tag to duplicate raises OrganizerError - with pytest.raises(OrganizerError): - await db.update_tag(t1.id, "tag2", "#aaaaaa") - - # Deleting tag removes it and cascades removal in mod_tags - await db.delete_tag(t2.id) - st = await db.load() - assert t2.id not in st.tags - assert "mod.alpha" not in st.mod_tags or st.mod_tags["mod.alpha"] == frozenset() - assert st.mod_tags["mod.beta"] == frozenset({t1.id}) - - # Deleting non-existent tag raises OrganizerError - with pytest.raises(OrganizerError): - await db.delete_tag(9999) - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_replace_rules_and_cascade_on_delete_folder(db: OrganizerDb) -> None: - try: - f1 = await db.create_folder(ROOT_ID, "Target1") - f2 = await db.create_folder(ROOT_ID, "Target2") - - spec1 = RuleSpec(field="name", op="prefix", pattern="Vanilla", folder_id=f1.id) - spec2 = RuleSpec( - field="author", op="contains", pattern="Oskar", folder_id=f2.id - ) - spec3 = RuleSpec( - field="package_id", - op="equals", - pattern="ludeon.rimworld", - folder_id=f1.id, - ) - - rules = await db.replace_rules([spec1, spec2, spec3]) - assert len(rules) == 3 - assert [r.position for r in rules] == [0, 1, 2] - assert rules[0].field == "name" - assert rules[1].field == "author" - assert rules[2].field == "package_id" - - st = await db.load() - assert st.rules == rules - - # Invalid field or op raises OrganizerError - with pytest.raises(OrganizerError): - bad_spec = RuleSpec( - field=cast(RuleField, "invalid"), - op="prefix", - pattern="x", - folder_id=f1.id, - ) - await db.replace_rules([bad_spec]) - with pytest.raises(OrganizerError): - bad_spec = RuleSpec(field="name", op="prefix", pattern="x", folder_id=9999) - await db.replace_rules([bad_spec]) - - # Deleting Target1 drops rule 0 and 2; rule 1 targeting Target2 remains - await db.delete_folder(f1.id) - st = await db.load() - assert len(st.rules) == 1 - assert st.rules[0].id == rules[1].id - assert st.rules[0].folder_id == f2.id - finally: - await db.close() - - -@pytest.mark.asyncio -async def test_state_survives_close_and_reopen(tmp_path: Path) -> None: - db_file = tmp_path / "organizer.db" - db1 = OrganizerDb(db_file) - try: - f = await db1.create_folder(ROOT_ID, "PersistFolder") - await db1.set_collapsed(f.id, True) - await db1.place(["persist.pkg"], f.id) - tag = await db1.create_tag("PersistTag", "#abcdef") - await db1.set_mod_tags(["persist.pkg"], add=[tag.id]) - await db1.replace_rules( - [RuleSpec(field="name", op="contains", pattern="mod", folder_id=f.id)] - ) - finally: - await db1.close() - - db2 = OrganizerDb(db_file) - try: - st = await db2.load() - assert f.id in st.folders - assert st.folders[f.id].name == "PersistFolder" - assert st.folders[f.id].collapsed is True - assert st.placements["persist.pkg"] == f.id - assert tag.id in st.tags - assert st.tags[tag.id].name == "PersistTag" - assert st.mod_tags["persist.pkg"] == frozenset({tag.id}) - assert len(st.rules) == 1 - assert st.rules[0].pattern == "mod" - assert st.rules[0].folder_id == f.id - finally: - db2.close_sync() - - -@pytest.mark.asyncio -async def test_append_named_rules_is_atomic(db: OrganizerDb) -> None: - try: - existing = await db.create_folder(ROOT_ID, "Existing") - await db.replace_rules( - [RuleSpec(field="name", op="contains", pattern="a", folder_id=existing.id)] - ) - before = await db.load() - - with pytest.raises(OrganizerError, match="Missing"): - await db.append_named_rules( - ROOT_ID, - ["New"], - [ - StandardRule("New", "name", "contains", "b"), - StandardRule("Missing", "name", "contains", "c"), - ], - ) - assert await db.load() == before - - await db.append_named_rules( - ROOT_ID, - ["New"], - [ - StandardRule("existing", "name", "contains", "b"), - StandardRule("NEW", "name", "contains", "c"), - ], - ) - state = await db.load() - new_id = next(f.id for f in state.folders.values() if f.name == "New") - assert [(r.position, r.pattern, r.folder_id) for r in state.rules] == [ - (0, "a", existing.id), - (1, "b", existing.id), - (2, "c", new_id), - ] - finally: - await db.close() diff --git a/tests/test_organizer/test_resolve.py b/tests/test_organizer/test_resolve.py deleted file mode 100644 index 011c496..0000000 --- a/tests/test_organizer/test_resolve.py +++ /dev/null @@ -1,364 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.core.organizer.models import ( - ROOT_ID, - Folder, - OrganizerState, - Rule, - RuleField, - RuleOp, - RuleSpec, - Tag, -) -from pxmodrim.core.organizer.resolve import ( - FolderNode, - TreeFilter, - TreeQuery, - build_tree, - folder_for, - preview_rule_matches, - tree_filters, -) - -ROOT = Folder(id=ROOT_ID, parent_id=None, name="") - - -def _state( - folders: list[Folder] | None = None, - placements: dict[str, int] | None = None, - rules: list[tuple[RuleField, RuleOp, str, int]] | None = None, - mod_tags: dict[str, frozenset[int]] | None = None, - tags: list[Tag] | dict[int, Tag] | None = None, -) -> OrganizerState: - all_folders = {ROOT_ID: ROOT} - for f in folders or []: - all_folders[f.id] = f - tag_dict: dict[int, Tag] = {} - if isinstance(tags, dict): - tag_dict = tags - elif tags: - tag_dict = {t.id: t for t in tags} - return OrganizerState( - folders=all_folders, - placements=placements or {}, - tags=tag_dict, - mod_tags=mod_tags or {}, - rules=tuple( - Rule(id=i + 1, position=i, field=fld, op=op, pattern=pat, folder_id=fid) - for i, (fld, op, pat, fid) in enumerate(rules or []) - ), - ) - - -def _mod( - uuid: str, - name: str, - pid: str | None = None, - author: str = "", - provider: str = "local", -) -> ListedMod: - if pid is None: - return ListedMod(name=name, _uuid=uuid, provider_id=provider) - return AboutXmlMod( - name=name, - _uuid=uuid, - provider_id=provider, - package_id=CaseInsensitiveStr(pid), - authors=[author] if author else [], - ) - - -def _find(root: FolderNode, folder_id: int) -> FolderNode: - return next(n for n in root.walk() if n.folder.id == folder_id) - - -def test_rule_preview_first_match_and_current_ungrouped() -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A"), Folder(3, ROOT_ID, "B")], - placements={"manual.mod": 3, "root.mod": ROOT_ID}, - rules=[("package_id", "prefix", "old.", 2)], - ) - mods = { - "a": _mod("a", "Core Alpha", "new.alpha", "Author"), - "b": _mod("b", "Core Beta", "old.beta", "Other"), - "c": _mod("c", "Core Manual", "manual.mod", "Author"), - "d": _mod("d", "Core Ungrouped", "root.mod", "Author"), - "e": _mod("e", "Core No ID"), - } - draft = [ - RuleSpec("author", "equals", "author", 3), - RuleSpec("name", "contains", "core", 2), - ] - assert preview_rule_matches(state, mods, draft) == ([3, 1], 1) - assert preview_rule_matches(state, mods, draft[::-1]) == ([4, 0], 1) - assert preview_rule_matches( - state, mods, [RuleSpec("name", "contains", " ", 2), *draft] - ) == ([0, 3, 1], 1) - - -class TestFolderFor: - def test_manual_beats_rule(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A"), Folder(3, ROOT_ID, "B")], - placements={"x.mod": 3}, - rules=[("package_id", "prefix", "x.", 2)], - ) - assert folder_for(state, "X.Mod", "Mod", "") == (3, "manual") - - def test_root_placement_blocks_rules(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - placements={"x.mod": ROOT_ID}, - rules=[("package_id", "prefix", "x.", 2)], - ) - assert folder_for(state, "x.mod", "Mod", "") == (ROOT_ID, "manual") - - def test_first_rule_by_position_wins(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A"), Folder(3, ROOT_ID, "B")], - rules=[ - ("author", "equals", "OSKAR", 3), - ("name", "contains", "core", 2), - ], - ) - assert folder_for(state, "o.core", "Core Stuff", "Oskar") == (3, "rule") - assert folder_for(state, "p.core", "Core Stuff", "Someone") == (2, "rule") - - def test_ops_are_case_insensitive(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - rules=[("name", "equals", "hello world", 2)], - ) - assert folder_for(state, "a.b", "Hello World", "") == (2, "rule") - assert folder_for(state, "a.b", "Hello World!", "") == (ROOT_ID, "none") - - def test_no_package_id_is_ungrouped(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - rules=[("name", "contains", "", 2)], - ) - assert folder_for(state, None, "Anything", "") == (ROOT_ID, "none") - - def test_no_match_is_ungrouped(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - rules=[("package_id", "prefix", "zzz", 2)], - ) - assert folder_for(state, "a.b", "Mod", "") == (ROOT_ID, "none") - - -class TestBuildTree: - def _nested(self) -> tuple[OrganizerState, dict[str, ListedMod]]: - state = _state( - folders=[ - Folder(2, ROOT_ID, "Zeta"), - Folder(3, 2, "inner"), - Folder(4, ROOT_ID, "alpha"), - ], - placements={"a.one": 2, "a.two": 3, "a.three": 3, "b.one": 4}, - ) - mods = { - "u1": _mod("u1", "One", "a.one"), - "u2": _mod("u2", "two", "a.two"), - "u3": _mod("u3", "Three", "a.three"), - "u4": _mod("u4", "B One", "b.one"), - "u5": _mod("u5", "loose"), - } - return state, mods - - def test_tri_state_across_nested_folders(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, {"u1", "u2", "u3"}) - zeta, inner, alpha = _find(root, 2), _find(root, 3), _find(root, 4) - assert (inner.total, inner.enabled, inner.check) == (2, 2, "on") - assert (zeta.total, zeta.enabled, zeta.check) == (3, 3, "on") - assert (alpha.total, alpha.enabled, alpha.check) == (1, 0, "off") - assert (root.total, root.enabled, root.check) == (5, 3, "partial") - - root = build_tree(state, mods, ["u2"]) - assert _find(root, 3).check == "partial" - assert _find(root, 2).check == "partial" - - def test_depths_and_root_holds_ungrouped(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, []) - assert root.depth == 0 - assert _find(root, 2).depth == 1 - assert _find(root, 3).depth == 2 - assert [m.uuid for m in root.mods] == ["u5"] - assert root.mods[0].placement == "none" - assert root.mods[0].package_id is None - - def test_sorted_by_name_casefold(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, []) - assert [c.folder.name for c in root.children] == ["alpha", "Zeta"] - assert [m.name for m in _find(root, 3).mods] == ["Three", "two"] - - def test_query_prunes_but_counts_cover_all_mods(self) -> None: - state, mods = self._nested() - root = build_tree(state, mods, {"u1"}, TreeQuery(text="TWO")) - assert [c.folder.id for c in root.children] == [2] - zeta = _find(root, 2) - assert zeta.mods == () - assert [m.uuid for m in _find(root, 3).mods] == ["u2"] - assert (zeta.total, zeta.enabled, zeta.visible_total) == (3, 1, 1) - assert (root.total, root.visible_total) == (5, 1) - assert root.mods == () - - def test_empty_query_does_not_prune(self) -> None: - state = _state(folders=[Folder(2, ROOT_ID, "Empty")]) - root = build_tree(state, {}, [], TreeQuery()) - assert [c.folder.id for c in root.children] == [2] - assert _find(root, 2).check == "off" - - def test_query_filters_status_tags_and_provider(self) -> None: - state = _state(mod_tags={"a.one": frozenset({1, 2}), "a.two": frozenset({1})}) - mods = { - "u1": _mod("u1", "One", "a.one", provider="steam"), - "u2": _mod("u2", "Two", "a.two", author="Ludeon"), - "u3": _mod("u3", "Three"), - } - - def visible(query: TreeQuery) -> set[str]: - root = build_tree(state, mods, {"u1"}, query) - return {m.uuid for m in root.all_mods()} - - assert visible(TreeQuery(status="active")) == {"u1"} - assert visible(TreeQuery(status="inactive")) == {"u2", "u3"} - assert visible(TreeQuery(tag_ids=frozenset({1}))) == {"u1", "u2"} - assert visible(TreeQuery(tag_ids=frozenset({1, 2}))) == {"u1"} - assert visible(TreeQuery(provider_ids=frozenset({"steam"}))) == {"u1"} - assert visible(TreeQuery(text="ludeon")) == {"u2"} - assert visible(TreeQuery(text="a.one")) == {"u1"} - - def test_shared_package_id_shares_placement_and_tags(self) -> None: - state = _state( - folders=[Folder(2, ROOT_ID, "A")], - placements={"dup.mod": 2}, - mod_tags={"dup.mod": frozenset({7})}, - ) - mods = { - "u1": _mod("u1", "Copy 1", "Dup.Mod"), - "u2": _mod("u2", "Copy 2", "dup.mod"), - } - folder = _find(build_tree(state, mods, []), 2) - assert [m.uuid for m in folder.mods] == ["u1", "u2"] - assert all(m.tag_ids == frozenset({7}) for m in folder.mods) - - def test_own_counts_and_rule_flag(self) -> None: - state, mods = self._nested() - state = _state( - folders=list(state.folders.values())[1:], - placements=state.placements, - rules=[("name", "contains", "zzz", 3)], - ) - root = build_tree(state, mods, {"u5", "u1"}, TreeQuery(text="nothing")) - assert (root.own_total, root.own_enabled, root.own_check) == (1, 1, "on") - assert (root.total, root.check) == (5, "partial") - full = build_tree(state, mods, set()) - assert [n.folder.id for n in full.walk() if n.has_rules] == [3] - assert _find(full, 2).own_total == 1 - - -class TestTreeFilters: - def test_query_includes_tag_id(self) -> None: - tf = TreeFilter("tag:10", "Medieval", 3, tag_id=10) - q = tf.query("sword") - assert q.tag_ids == frozenset({10}) - assert q.text == "sword" - assert q.status == "all" - assert q.provider_ids == frozenset() - assert not q.is_empty - - def test_tree_filters_preserves_order_and_counts_tags(self) -> None: - state = _state( - tags=[ - Tag(id=1, name="Medieval", color="#5eead4"), - Tag(id=2, name="Combat", color="#fb923c"), - Tag(id=3, name="Magic", color="#a3e635"), - ], - mod_tags={ - "a.one": frozenset({1, 2}), - "a.two": frozenset({1}), - }, - ) - mods = { - "u1": _mod("u1", "One", "a.one", provider="steam"), - "u2": _mod("u2", "Two", "a.two", provider="local"), - "u3": _mod("u3", "Three", "a.three", provider="steam"), - } - filters = tree_filters(mods, {"u1"}, state) - assert [f.key for f in filters[:3]] == ["all", "active", "inactive"] - assert [f.count for f in filters[:3]] == [3, 1, 2] - - providers = [f for f in filters if f.provider_id is not None] - assert [f.key for f in providers] == ["provider:local", "provider:steam"] - assert [f.count for f in providers] == [1, 2] - - tag_filters = [f for f in filters if f.tag_id is not None] - assert [f.key for f in tag_filters] == ["tag:2", "tag:3", "tag:1"] - assert [f.label for f in tag_filters] == ["Combat", "Magic", "Medieval"] - assert [f.count for f in tag_filters] == [1, 0, 2] - - def test_tree_filters_shared_package_ids_count_all_mods(self) -> None: - state = _state( - tags=[Tag(id=5, name="Overhaul", color="#5eead4")], - mod_tags={"shared.mod": frozenset({5})}, - ) - mods = { - "u1": _mod("u1", "Steam Copy", "Shared.Mod", provider="steam"), - "u2": _mod("u2", "Local Copy", "shared.mod", provider="local"), - "u3": _mod("u3", "Other Mod", "other.mod", provider="local"), - } - filters = tree_filters(mods, set(), state) - tag_filter = next(f for f in filters if f.key == "tag:5") - assert tag_filter.count == 2 - assert tag_filter.label == "Overhaul" - assert tag_filter.tag_id == 5 - - def test_tree_filters_without_state_or_without_tags(self) -> None: - mods = {"u1": _mod("u1", "One", "a.one")} - assert [f.key for f in tree_filters(mods, set(), None)] == [ - "all", - "active", - "inactive", - "provider:local", - ] - assert [f.key for f in tree_filters(mods, set(), _state())] == [ - "all", - "active", - "inactive", - "provider:local", - ] - - def test_filtering_tree_with_tag_query(self) -> None: - state = _state( - tags=[ - Tag(id=1, name="Tagged", color="#5eead4"), - Tag(id=2, name="Empty", color="#fb923c"), - ], - mod_tags={"a.one": frozenset({1}), "a.two": frozenset({1})}, - ) - mods = { - "u1": _mod("u1", "Mod One", "a.one"), - "u2": _mod("u2", "Mod Two", "a.two"), - "u3": _mod("u3", "Mod Three", "a.three"), - } - filters = tree_filters(mods, set(), state) - tag1_filter = next(f for f in filters if f.key == "tag:1") - tree = build_tree(state, mods, set(), tag1_filter.query()) - assert {m.uuid for m in tree.all_mods()} == {"u1", "u2"} - - empty_filter = next(f for f in filters if f.key == "tag:2") - empty_tree = build_tree(state, mods, set(), empty_filter.query()) - assert list(empty_tree.all_mods()) == [] - assert empty_tree.visible_total == 0 - - narrowed = build_tree(state, mods, set(), tag1_filter.query("Two")) - assert {m.uuid for m in narrowed.all_mods()} == {"u2"} diff --git a/tests/test_organizer/test_service.py b/tests/test_organizer/test_service.py deleted file mode 100644 index 8087955..0000000 --- a/tests/test_organizer/test_service.py +++ /dev/null @@ -1,470 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncIterator -from pathlib import Path -from typing import TYPE_CHECKING, cast - -import pytest - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.core.organizer.db import OrganizerDb -from pxmodrim.core.organizer.defaults import STANDARD_RULES -from pxmodrim.core.organizer.models import ROOT_ID, OrganizerError, RuleSpec -from pxmodrim.core.organizer.resolve import folder_for -from pxmodrim.core.organizer.service import OrganizerService - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - - -class _FakeCtx: - def __init__(self) -> None: - self.all_mods: dict[str, ListedMod] = {} - self.active_uuids: list[str] = [] - - def add(self, uuid: str, name: str, pid: str) -> None: - self.all_mods[uuid] = AboutXmlMod( - name=name, _uuid=uuid, package_id=CaseInsensitiveStr(pid) - ) - - -class _Counter: - def __init__(self) -> None: - self.count = 0 - - def __call__(self, _: None) -> None: - self.count += 1 - - -@pytest.fixture -def ctx() -> _FakeCtx: - return _FakeCtx() - - -@pytest.fixture -async def svc(tmp_path: Path, ctx: _FakeCtx) -> AsyncIterator[OrganizerService]: - service = OrganizerService( - cast("CoreContext", ctx), OrganizerDb(tmp_path / "organizer.db") - ) - await service.init(cast("CoreContext", ctx)) - yield service - await service.shutdown() - - -@pytest.fixture -def changes(svc: OrganizerService) -> _Counter: - counter = _Counter() - svc.changed.connect(counter) - return counter - - -async def test_bulk_folder_expansion_persists(svc: OrganizerService) -> None: - parent = await svc.create_folder("Parent") - child = await svc.create_folder("Child", parent.id) - other = await svc.create_folder("Other") - - await svc.set_all_collapsed(True) - persisted = await svc._db.load() - assert all( - persisted.folders[folder.id].collapsed for folder in (parent, child, other) - ) - - await svc.set_collapsed(child.id, False) - await svc.set_all_collapsed(False) - persisted = await svc._db.load() - assert all( - not persisted.folders[folder.id].collapsed for folder in (parent, child, other) - ) - - -async def test_depth_limit(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - c = await svc.create_folder("C", b.id) - assert changes.count == 3 - with pytest.raises(OrganizerError, match="at most 3 levels"): - await svc.create_folder("D", c.id) - assert changes.count == 3 - assert len(svc.state.folders) == 4 - - -async def test_move_respects_subtree_height( - svc: OrganizerService, changes: _Counter -) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - x = await svc.create_folder("X") - y = await svc.create_folder("Y", x.id) - with pytest.raises(OrganizerError, match="at most 3 levels"): - await svc.move_folder(x.id, b.id) - await svc.move_folder(x.id, a.id) - assert svc.state.folders[x.id].parent_id == a.id - assert svc.state.folders[y.id].parent_id == x.id - assert changes.count == 5 - - -async def test_cycle_rejected(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - for target in (a.id, b.id): - with pytest.raises(OrganizerError, match="into itself"): - await svc.move_folder(a.id, target) - with pytest.raises(OrganizerError): - await svc.move_folder(ROOT_ID, a.id) - assert changes.count == 2 - assert svc.state.folders[a.id].parent_id == ROOT_ID - - -async def test_duplicate_name_message(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("Graphics") - with pytest.raises(OrganizerError, match='A folder named "graphics" already'): - await svc.create_folder(" graphics ") - other = await svc.create_folder("Other") - with pytest.raises(OrganizerError, match="already exists"): - await svc.rename_folder(other.id, "GRAPHICS") - await svc.create_folder("Graphics", other.id) - await svc.rename_folder(a.id, "graphics") - assert svc.state.folders[a.id].name == "graphics" - assert changes.count == 4 - - -async def test_empty_name_rejected(svc: OrganizerService, changes: _Counter) -> None: - with pytest.raises(OrganizerError, match="cannot be empty"): - await svc.create_folder(" ") - assert changes.count == 0 - - -async def test_create_folder_from_emits_once_and_rejects_duplicate( - svc: OrganizerService, changes: _Counter -) -> None: - initial_changes = changes.count - f = await svc.create_folder_from("Core", ["mod.a", "mod.b"]) - assert changes.count == initial_changes + 1 - assert f.name == "Core" - assert svc.state.placements["mod.a"] == f.id - assert svc.state.placements["mod.b"] == f.id - - state_before = svc.state - changes_before = changes.count - - with pytest.raises(OrganizerError): - await svc.create_folder_from("core", ["mod.c"]) - - assert changes.count == changes_before - assert svc.state == state_before - db_state = await svc._db.load() - assert "mod.c" not in db_state.placements - assert len(db_state.folders) == len(state_before.folders) - - -async def test_delete_folder_ungroups_mods( - svc: OrganizerService, ctx: _FakeCtx, changes: _Counter -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.active_uuids = ["u1"] - a = await svc.create_folder_from("A", ["A.One"]) - b = await svc.create_folder("B", a.id) - await svc.place(["a.two"], b.id) - await svc.set_rules([RuleSpec("package_id", "prefix", "a.", a.id)]) - assert changes.count == 4 - assert sorted(svc.folder_mod_uuids(a.id)) == ["u1", "u2"] - - await svc.delete_folder(a.id) - - assert changes.count == 5 - assert set(svc.state.folders) == {ROOT_ID} - assert svc.state.rules == () - root = svc.tree() - assert sorted(m.uuid for m in root.mods) == ["u1", "u2"] - assert all(m.placement == "manual" for m in root.mods) - assert (root.total, root.enabled, root.check) == (2, 1, "partial") - - -async def test_place_ungroup_and_reset( - svc: OrganizerService, ctx: _FakeCtx, changes: _Counter -) -> None: - ctx.add("u1", "One", "a.one") - a = await svc.create_folder("A") - await svc.set_rules([RuleSpec("name", "equals", " one ", a.id)]) - assert svc.state.rules[0].pattern == "one" - assert svc.tree().children[0].mods[0].placement == "rule" - - await svc.ungroup(["a.one"]) - assert svc.tree().mods[0].placement == "manual" - - await svc.reset_to_rules(["a.one"]) - assert svc.tree().children[0].mods[0].placement == "rule" - assert changes.count == 4 - - -async def test_rules_validation(svc: OrganizerService, changes: _Counter) -> None: - a = await svc.create_folder("A") - with pytest.raises(OrganizerError, match="root"): - await svc.set_rules([RuleSpec("name", "equals", "x", ROOT_ID)]) - with pytest.raises(OrganizerError, match="pattern cannot be empty"): - await svc.set_rules([RuleSpec("name", "equals", " ", a.id)]) - with pytest.raises(OrganizerError): - await svc.set_rules([RuleSpec("name", "equals", "x", 999)]) - assert changes.count == 1 - - -async def test_tags(svc: OrganizerService, changes: _Counter) -> None: - tag = await svc.create_tag("QoL", "#aabbcc") - with pytest.raises(OrganizerError, match="already exists"): - await svc.create_tag("qol", "#000000") - with pytest.raises(OrganizerError, match="not a valid color"): - await svc.create_tag("Other", "red") - await svc.set_mod_tags(["A.Mod"], add=[tag.id]) - assert svc.state.mod_tags["a.mod"] == frozenset({tag.id}) - with pytest.raises(OrganizerError): - await svc.set_mod_tags(["a.mod"], add=[999]) - await svc.delete_tag(tag.id) - assert svc.state.tags == {} - assert not svc.state.mod_tags.get("a.mod") - assert changes.count == 3 - - -async def test_state_persists(tmp_path: Path, ctx: _FakeCtx) -> None: - path = tmp_path / "organizer.db" - first = OrganizerService(cast("CoreContext", ctx), OrganizerDb(path)) - await first.init(cast("CoreContext", ctx)) - folder = await first.create_folder("Kept") - await first.shutdown() - - second = OrganizerService(cast("CoreContext", ctx), OrganizerDb(path)) - await second.init(cast("CoreContext", ctx)) - assert second.state.folders[folder.id].name == "Kept" - await second.shutdown() - - -async def test_init_signals_readiness(tmp_path: Path, ctx: _FakeCtx) -> None: - service = OrganizerService( - cast("CoreContext", ctx), OrganizerDb(tmp_path / "organizer.db") - ) - counter = _Counter() - service.changed.connect(counter) - assert not service.ready - await service.init(cast("CoreContext", ctx)) - assert service.ready - assert counter.count == 1 - await service.shutdown() - - -async def test_folder_toggle_enables_partial_and_disables_full( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.add("u3", "Loose", "loose.mod") - a = await svc.create_folder_from("A", ["a.one"]) - b = await svc.create_folder("B", a.id) - await svc.place(["a.two"], b.id) - - ctx.active_uuids = ["u1"] - enable, disable = svc.folder_toggle(a.id) - assert (sorted(enable), disable) == (["u1", "u2"], []) - - ctx.active_uuids = ["u1", "u2"] - enable, disable = svc.folder_toggle(a.id) - assert (enable, sorted(disable)) == ([], ["u1", "u2"]) - - assert svc.folder_toggle(ROOT_ID, recursive=False) == (["u3"], []) - ctx.active_uuids = ["u3"] - assert svc.folder_toggle(ROOT_ID, recursive=False) == ([], ["u3"]) - assert svc.folder_mod_uuids(ROOT_ID, recursive=False) == ["u3"] - - -async def test_folder_move_targets_respect_subtree_depth_and_names( - svc: OrganizerService, -) -> None: - a = await svc.create_folder("A") - b = await svc.create_folder("B", a.id) - c = await svc.create_folder("C", b.id) - x = await svc.create_folder("X") - y = await svc.create_folder("Y", x.id) - clash = await svc.create_folder("Clash") - inner_x = await svc.create_folder("X", clash.id) - - assert svc.folder_move_targets(b.id) == {x.id, clash.id, ROOT_ID} - assert svc.folder_move_targets(x.id) == {a.id} - assert svc.folder_move_targets(c.id) == { - a.id, - x.id, - y.id, - clash.id, - inner_x.id, - ROOT_ID, - } - with pytest.raises(OrganizerError): - svc.folder_move_targets(ROOT_ID) - - -async def test_tree_filters_count_status_and_providers( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.active_uuids = ["u1"] - filters = {f.key: f for f in svc.tree_filters()} - assert [(k, f.count) for k, f in filters.items()][:3] == [ - ("all", 2), - ("active", 1), - ("inactive", 1), - ] - provider = next(f for f in filters.values() if f.provider_id is not None) - assert provider.count == 2 - assert provider.query("x").provider_ids == frozenset({provider.provider_id}) - assert filters["active"].query("one").status == "active" - - -async def test_tree_filters_with_persisted_tags_and_empty_tag( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "One", "a.one") - ctx.add("u2", "Two", "a.two") - ctx.add("u3", "Three", "a.three") - - t1 = await svc.create_tag("Medieval", "#5eead4") - t2 = await svc.create_tag("Combat", "#fb923c") - t3 = await svc.create_tag("Empty", "#a3e635") - - await svc.set_mod_tags(["a.one"], add=[t1.id, t2.id]) - await svc.set_mod_tags(["a.two"], add=[t1.id]) - - filters = {f.key: f for f in svc.tree_filters()} - assert f"tag:{t1.id}" in filters - assert filters[f"tag:{t1.id}"].count == 2 - assert filters[f"tag:{t1.id}"].label == "Medieval" - assert filters[f"tag:{t1.id}"].tag_id == t1.id - - assert filters[f"tag:{t2.id}"].count == 1 - assert filters[f"tag:{t2.id}"].label == "Combat" - - assert filters[f"tag:{t3.id}"].count == 0 - assert filters[f"tag:{t3.id}"].label == "Empty" - - await svc.delete_tag(t2.id) - after_del = {f.key: f for f in svc.tree_filters()} - assert f"tag:{t2.id}" not in after_del - assert f"tag:{t1.id}" in after_del - - -async def test_tree_filters_shared_package_ids_count_in_service( - svc: OrganizerService, ctx: _FakeCtx -) -> None: - ctx.add("u1", "Steam Copy", "shared.pkg") - ctx.add("u2", "Local Copy", "shared.pkg") - ctx.add("u3", "Other Mod", "other.pkg") - - tag = await svc.create_tag("Overhaul", "#5eead4") - await svc.set_mod_tags(["shared.pkg"], add=[tag.id]) - - filters = {f.key: f for f in svc.tree_filters()} - assert filters[f"tag:{tag.id}"].count == 2 - - -def _rule_rows(svc: OrganizerService) -> list[tuple[str, str, str, str]]: - folders = svc.state.folders - return [ - (folders[r.folder_id].name, r.field, r.op, r.pattern) for r in svc.state.rules - ] - - -def _standard_rows() -> list[tuple[str, str, str, str]]: - return [(r.folder, r.field, r.op, r.pattern) for r in STANDARD_RULES] - - -async def test_add_standard_rules_fresh_and_idempotent( - svc: OrganizerService, changes: _Counter -) -> None: - total = len(STANDARD_RULES) - names = {r.folder for r in STANDARD_RULES} - assert await svc.add_standard_rules() == total - assert changes.count == 1 - assert _rule_rows(svc) == _standard_rows() - top = [f for f in svc.state.folders.values() if f.parent_id == ROOT_ID] - assert sorted(f.name for f in top) == sorted(names) - assert len(svc.state.folders) == len(names) + 1 - assert [r.position for r in svc.state.rules] == list(range(total)) - - assert await svc.add_standard_rules() == 0 - assert changes.count == 1 - assert len(svc.state.rules) == total - assert len(svc.state.folders) == len(names) + 1 - - -async def test_add_standard_rules_reuses_folder_and_keeps_user_rules( - svc: OrganizerService, changes: _Counter -) -> None: - official = await svc.create_folder("OFFICIAL") - mine = await svc.create_folder("Mine") - await svc.set_rules( - [ - RuleSpec("author", "contains", "me", mine.id), - RuleSpec("package_id", "prefix", "SARG.", mine.id), - ] - ) - changes.count = 0 - - assert await svc.add_standard_rules() == len(STANDARD_RULES) - 1 - assert changes.count == 1 - names = [f.name for f in svc.state.folders.values()] - assert "Official" not in names - assert "Alpha Mods" not in names - assert names.count("OFFICIAL") == 1 - rows = _rule_rows(svc) - assert rows[:2] == [ - ("Mine", "author", "contains", "me"), - ("Mine", "package_id", "prefix", "SARG."), - ] - expected = [row for row in _standard_rows() if row[3] != "sarg."] - expected[0] = ("OFFICIAL", *expected[0][1:]) - assert rows[2:] == expected - assert svc.state.rules[2].folder_id == official.id - - -async def test_standard_rules_resolve_mods(svc: OrganizerService) -> None: - await svc.add_standard_rules() - state = svc.state - - def folder(pid: str, name: str = "Mod", author: str = "") -> str: - return state.folders[folder_for(state, pid, name, author)[0]].name - - assert folder("oskarpotocki.vfe.core") == "Vanilla Expanded" - assert folder("someone.vfe", author="Oskar Potocki, Taranchuk") == ( - "Vanilla Expanded" - ) - assert folder("ludeon.rimworld.biotech", "Biotech") == "Official" - assert folder("brrainz.harmony", "Harmony") == "Frameworks & Libraries" - assert folder("ceteam.combatextended", "Combat Extended") == "Combat Extended" - assert folder("sarg.alphagenes") == "Alpha Mods" - assert folder("bs.performance", "Performance Fish") == "Performance" - assert folder("oskarpotocki.vanillafactionsexpanded.core") == ( - "Frameworks & Libraries" - ) - assert folder("vanillaracesexpanded.sanguophage") == "Vanilla Expanded" - assert folder("dubwise.dubsperformanceanalyzer.steam") == "Performance" - assert folder("dubwise.rimatomics") == "Dubs Mods" - assert folder("brrainz.achtung", "Achtung!") == "Quality of Life" - assert folder("brrainz.jobsofopportunity") == "Root" - assert folder("smartkar.athena.framework", "Athena Framework") == "Root" - assert folder("someone.perf", "Performance Tweaks") == "Root" - assert folder("someone.other", "Other") == "Root" - - -async def test_standard_package_id_rules_are_not_shadowed( - svc: OrganizerService, -) -> None: - await svc.add_standard_rules() - state = svc.state - for rule in STANDARD_RULES: - if rule.field != "package_id": - continue - probe = rule.pattern if rule.op == "equals" else rule.pattern + "x" - folder_id, _ = folder_for(state, probe, "Mod", "") - assert state.folders[folder_id].name == rule.folder, rule.pattern diff --git a/tests/test_packaging.py b/tests/test_packaging.py deleted file mode 100644 index 9f5f0bf..0000000 --- a/tests/test_packaging.py +++ /dev/null @@ -1,288 +0,0 @@ -from __future__ import annotations - -import configparser -import importlib.util -import plistlib -import struct -import xml.etree.ElementTree as ET -import zipfile -from pathlib import Path - -import pytest - -_build_py = Path(__file__).parent.parent / "packaging" / "build.py" -_spec = importlib.util.spec_from_file_location("local_packaging_build", _build_py) -assert _spec and _spec.loader -pkg_build = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(pkg_build) - - -def test_get_version_from_pyproject(tmp_path: Path, monkeypatch) -> None: - monkeypatch.delenv("GITHUB_REF_NAME", raising=False) - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") - assert pkg_build.get_version(tmp_path) == "1.2.3" - - -def test_get_version_from_git_tag(tmp_path: Path, monkeypatch) -> None: - monkeypatch.setenv("GITHUB_REF_NAME", "v2.0.1") - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nversion = "1.0.0"\n', encoding="utf-8") - assert pkg_build.get_version(tmp_path) == "2.0.1" - - -@pytest.mark.parametrize( - ("machine", "architecture"), - [("x86_64", "amd64"), ("aarch64", "arm64")], -) -def test_debian_architecture_mapping(machine: str, architecture: str) -> None: - assert pkg_build.debian_architecture(machine) == architecture - - -def test_deb_creation_uses_native_architecture(tmp_path: Path, monkeypatch) -> None: - dist_dir = tmp_path / "dist" / "entrypoint.dist" - dist_dir.mkdir(parents=True) - (dist_dir / "PxModRim").write_bytes(b"application") - (tmp_path / "pyproject.toml").write_text( - '[project]\nversion = "0.5.0"\n', encoding="utf-8" - ) - desktop_dir = tmp_path / "packaging" / "linux" - desktop_dir.mkdir(parents=True) - (desktop_dir / "pxmodrim.desktop").write_text("[Desktop Entry]\n", encoding="utf-8") - icon = tmp_path / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg" - icon.parent.mkdir(parents=True) - icon.write_text("", encoding="utf-8") - - control: dict[str, str] = {} - monkeypatch.setattr(pkg_build.platform, "machine", lambda: "aarch64") - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: "dpkg-deb") - - def build_package(args: list[str], check: bool) -> None: - control["metadata"] = (Path(args[-2]) / "DEBIAN" / "control").read_text( - encoding="utf-8" - ) - Path(args[-1]).touch() - - monkeypatch.setattr(pkg_build.subprocess, "run", build_package) - - output = pkg_build.create_deb(tmp_path) - - assert output is not None - assert output.name == "pxmodrim_0.5.0_arm64.deb" - assert "Architecture: arm64" in control["metadata"] - - -def test_get_standalone_platform_args(monkeypatch) -> None: - monkeypatch.setattr(pkg_build.platform, "system", lambda: "Darwin") - args = pkg_build.get_standalone_args() - assert "--macos-create-app-bundle" in args - assert "--macos-app-name=PxModRim" in args - assert "--macos-signed-app-name=com.github.PyXiion.PxModRim" in args - - monkeypatch.setattr(pkg_build.platform, "system", lambda: "Windows") - args = pkg_build.get_standalone_args() - assert "--windows-company-name=PxModRim" in args - assert "--windows-product-name=PxModRim" in args - - -@pytest.mark.parametrize( - ("workshop", "left_out", "shipped"), - [ - ( - "NoWorkshop", - [ - "pxsteamdl", - "ui.plugins.steam_workshop", - "ui.plugins.workshop", - "ui.plugins.downloads", - "QtWebEngine", - ], - [], - ), - ( - "SteamWorkshop", - ["ui.plugins.workshop"], - ["pxsteamdl", "ui.plugins.steam_workshop", "ui.plugins.downloads"], - ), - ( - "NativeWorkshop", - ["ui.plugins.steam_workshop", "QtWebEngine"], - ["pxsteamdl", "ui.plugins.workshop", "ui.plugins.downloads"], - ), - ], -) -def test_workshop_variants_leave_out_only_what_they_do_not_ship( - workshop: str, left_out: list[str], shipped: list[str] -) -> None: - options = " ".join(pkg_build.get_standalone_args(workshop=workshop)) - assert all(name in options for name in left_out) - assert not any(name in options for name in shipped) - - -def test_strip_unused_qml_modules_leaves_missing_directory_unchanged( - tmp_path: Path, -) -> None: - sentinel = tmp_path / "keep.txt" - sentinel.write_text("keep", encoding="utf-8") - - pkg_build.strip_unused_qml_modules(tmp_path) - - assert sentinel.read_text(encoding="utf-8") == "keep" - - -def test_windows_executable_normalization(tmp_path: Path) -> None: - dist_dir = tmp_path / "entrypoint.dist" - dist_dir.mkdir() - source = dist_dir / "entrypoint.exe" - source.write_bytes(b"windows executable") - - result = pkg_build.normalize_executable(dist_dir, system="Windows") - - assert result == dist_dir / "PxModRim.exe" - assert result.read_bytes() == b"windows executable" - assert not source.exists() - - with pytest.raises(FileNotFoundError, match="Executable not found"): - pkg_build.normalize_executable(tmp_path / "missing", system="Windows") - - -def test_macos_executable_normalization_preserves_runtime_directory( - tmp_path: Path, -) -> None: - entrypoint = tmp_path / "entrypoint" - entrypoint.write_bytes(b"macOS executable") - runtime_dir = tmp_path / "PxModRim" - runtime_dir.mkdir() - runtime_file = runtime_dir / "module.pyd" - runtime_file.write_bytes(b"runtime module") - - result = pkg_build.normalize_executable(tmp_path, system="Darwin") - - assert result == entrypoint - assert result.read_bytes() == b"macOS executable" - assert runtime_file.read_bytes() == b"runtime module" - - -def test_deb_creation_requires_dpkg_deb(tmp_path: Path, monkeypatch) -> None: - dist_dir = tmp_path / "dist" / "entrypoint.dist" - dist_dir.mkdir(parents=True) - (tmp_path / "pyproject.toml").write_text( - '[project]\nversion = "0.5.0"\n', encoding="utf-8" - ) - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="dpkg-deb"): - pkg_build.create_deb(tmp_path) - - -def test_appimage_creation_requires_appimagetool_for_release( - tmp_path: Path, monkeypatch -) -> None: - dist_dir = tmp_path / "dist" / "entrypoint.dist" - dist_dir.mkdir(parents=True) - (dist_dir / "PxModRim").write_bytes(b"application") - icon = tmp_path / "src" / "pxmodrim" / "ui" / "assets" / "logo.svg" - icon.parent.mkdir(parents=True) - icon.write_text("", encoding="utf-8") - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="appimagetool"): - pkg_build.create_appimage(tmp_path, release=True) - - -def test_rpm_creation_requires_rpmbuild_for_release( - tmp_path: Path, monkeypatch -) -> None: - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="rpmbuild"): - pkg_build.create_rpm(tmp_path, release=True) - - -def test_flatpak_metadata_contract() -> None: - metadata = ET.parse( - "packaging/flatpak/com.github.PyXiion.PxModRim.metainfo.xml" - ).getroot() - assert metadata.findtext("id") == "com.github.PyXiion.PxModRim" - assert metadata.findtext("project_license") == "GPL-3.0-or-later" - urls = {url.attrib["type"]: url.text for url in metadata.findall("url")} - assert urls == { - "homepage": "https://github.com/PyXiion/PxModRim", - "bugtracker": "https://github.com/PyXiion/PxModRim/issues", - } - - -def test_desktop_entry_contract() -> None: - desktop = configparser.ConfigParser(interpolation=None) - desktop.read("packaging/flatpak/com.github.PyXiion.PxModRim.desktop") - entry = desktop["Desktop Entry"] - assert entry["Icon"] == "com.github.PyXiion.PxModRim" - assert entry["Exec"] == "PxModRim" - - -def test_logo_icns_header() -> None: - icns = Path("packaging/logo.icns").read_bytes() - assert icns[:4] == b"icns" - total_len = struct.unpack(">I", icns[4:8])[0] - assert total_len == len(icns) - assert icns[8:12] == b"ic09" - - -def test_macos_bundle_plist_update(tmp_path: Path, monkeypatch) -> None: - project_root = tmp_path - pyproject = project_root / "pyproject.toml" - pyproject.write_text('[project]\nversion = "0.9.0"\n', encoding="utf-8") - - app_dir = project_root / "dist" / "PxModRim.app" - contents = app_dir / "Contents" - macos_dir = contents / "MacOS" - macos_dir.mkdir(parents=True) - (macos_dir / "entrypoint").write_bytes(b"macOS executable") - runtime_dir = macos_dir / "PxModRim" - runtime_dir.mkdir() - (runtime_dir / "module.pyd").write_bytes(b"runtime module") - plist_file = contents / "Info.plist" - - with open(plist_file, "wb") as f: - plistlib.dump({}, f) - - # Mock the platform tools while exercising app metadata and archive creation. - commands: list[list[str]] = [] - monkeypatch.setattr( - pkg_build.subprocess, - "run", - lambda command, check: commands.append(command), - ) - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - res = pkg_build.create_macos_bundle(project_root) - assert res == app_dir - assert (macos_dir / "entrypoint").read_bytes() == b"macOS executable" - assert (runtime_dir / "module.pyd").read_bytes() == b"runtime module" - - with open(plist_file, "rb") as f: - pl = plistlib.load(f) - - assert pl["CFBundleIdentifier"] == "com.github.PyXiion.PxModRim" - assert pl["CFBundleName"] == "PxModRim" - assert pl["CFBundleVersion"] == "0.9.0" - assert pl["CFBundleExecutable"] == "entrypoint" - assert pl["CFBundlePackageType"] == "APPL" - - assert any(command[0] == "codesign" and "-" in command for command in commands) - with zipfile.ZipFile(project_root / "dist" / "PxModRim-macOS.zip") as archive: - assert "PxModRim.app/Contents/MacOS/entrypoint" in archive.namelist() - - -def test_macos_bundle_requires_hdiutil_for_release(tmp_path: Path, monkeypatch) -> None: - pyproject = tmp_path / "pyproject.toml" - pyproject.write_text('[project]\nversion = "0.9.0"\n', encoding="utf-8") - app_dir = tmp_path / "dist" / "PxModRim.app" - macos_dir = app_dir / "Contents" / "MacOS" - macos_dir.mkdir(parents=True) - (macos_dir / "entrypoint").write_bytes(b"macOS executable") - monkeypatch.setattr(pkg_build.subprocess, "run", lambda command, check: None) - monkeypatch.setattr(pkg_build.shutil, "which", lambda cmd: None) - - with pytest.raises(RuntimeError, match="hdiutil"): - pkg_build.create_macos_bundle(tmp_path, release=True) diff --git a/tests/test_plugin.py b/tests/test_plugin.py deleted file mode 100644 index 2660f7d..0000000 --- a/tests/test_plugin.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -import asyncio -import os -from pathlib import Path -from unittest.mock import patch - -import pytest - -from pxmodrim._app import _parse_disabled_plugins -from pxmodrim.core.plugin import Plugin, PluginRegistry - - -class _RecorderPlugin(Plugin): - def __init__(self, name: str, dependencies: list[str] | None = None) -> None: - object.__setattr__(self, "name", name) - object.__setattr__(self, "dependencies", dependencies or []) - - def setup(self, ctx: list[str]) -> None: - ctx.append(self.name) - - async def init(self, ctx: list[str]) -> None: - ctx.append(self.name) - - -class TestPluginRegistryToposort: - def test_empty_registry(self) -> None: - reg = PluginRegistry() - order: list[str] = [] - reg.setup_all(order) - assert order == [] - - def test_no_dependencies(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b")) - reg.register(_RecorderPlugin("c")) - order: list[str] = [] - reg.setup_all(order) - assert order == ["a", "b", "c"] - - def test_linear_chain(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b", ["a"])) - reg.register(_RecorderPlugin("c", ["b"])) - order: list[str] = [] - reg.setup_all(order) - assert order == ["a", "b", "c"] - - def test_complex_dag(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b", ["a"])) - reg.register(_RecorderPlugin("c", ["a"])) - reg.register(_RecorderPlugin("d", ["b", "c"])) - reg.register(_RecorderPlugin("e", ["d"])) - reg.register(_RecorderPlugin("f")) - reg.register(_RecorderPlugin("g", ["e", "f"])) - order: list[str] = [] - reg.setup_all(order) - assert order == ["a", "f", "b", "c", "d", "e", "g"] - - def test_missing_dependency_raises(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a", ["nonexistent"])) - with pytest.raises(RuntimeError, match="depends on 'nonexistent'"): - reg.setup_all(None) - - def test_extra_deps(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a", ["external_dep"])) - order: list[str] = [] - reg.setup_all(order, extra_deps={"external_dep"}) - assert order == ["a"] - - def test_init_all_order(self) -> None: - reg = PluginRegistry() - reg.register(_RecorderPlugin("a")) - reg.register(_RecorderPlugin("b", ["a"])) - reg.register(_RecorderPlugin("c", ["b"])) - order: list[str] = [] - asyncio.run(reg.init_all(order)) - assert order == ["a", "b", "c"] - - def test_shutdown_all_reverse_dependency_order(self) -> None: - reg = PluginRegistry() - shutdown_order: list[str] = [] - - class ShutdownRecorder(_RecorderPlugin): - async def shutdown(self) -> None: - shutdown_order.append(self.name) - - reg.register(ShutdownRecorder("dependent", ["dependency"])) - reg.register(ShutdownRecorder("dependency")) - - asyncio.run(reg.shutdown_all()) - - assert shutdown_order == ["dependent", "dependency"] - - -class TestParseDisabledPlugins: - def test_empty(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": ""}): - assert _parse_disabled_plugins() == set() - - def test_single(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": "workshop_download"}): - assert _parse_disabled_plugins() == {"workshop_download"} - - def test_multiple(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": "a, b, c"}): - assert _parse_disabled_plugins() == {"a", "b", "c"} - - def test_empty_entries(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": "a,,b"}): - assert _parse_disabled_plugins() == {"a", "b"} - - def test_whitespace_around_names(self) -> None: - with patch.dict( - os.environ, - {"PX_DISABLED_PLUGINS": " workshop_download , steamworkshop "}, - ): - assert _parse_disabled_plugins() == {"workshop_download", "steamworkshop"} - - def test_not_set(self) -> None: - with patch.dict(os.environ, {"PX_DISABLED_PLUGINS": ""}): - os.environ.pop("PX_DISABLED_PLUGINS", None) - assert _parse_disabled_plugins() == set() - - -@pytest.mark.parametrize( - ("variant", "disabled"), - [ - ( - "NoWorkshop", - { - "steam_downloader", - "steamworkshop", - "downloads_ui", - "workshop_catalog", - "workshop_ui", - }, - ), - ("SteamWorkshop", {"workshop_catalog", "workshop_ui"}), - ("NativeWorkshop", {"steamworkshop"}), - ], -) -def test_baked_workshop_variant_disables_the_plugins_it_omits( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, variant: str, disabled: set[str] -) -> None: - (tmp_path / "workshop-variant.txt").write_text(variant, encoding="utf-8") - monkeypatch.setattr("pxmodrim._app.resource_files", lambda _: tmp_path) - monkeypatch.delenv("PX_DISABLED_PLUGINS", raising=False) - assert _parse_disabled_plugins() == disabled diff --git a/tests/test_providers.py b/tests/test_providers.py deleted file mode 100644 index 0bc834d..0000000 --- a/tests/test_providers.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core.config import PathConfig -from pxmodrim.core.models.view.sidebar import PROVIDER_LABELS -from pxmodrim.core.providers import SteamWorkshopModProvider, create_providers - - -@pytest.mark.asyncio -async def test_workshop_path_has_distinct_provider_and_local_roots( - tmp_path: Path, -) -> None: - local_path = tmp_path / "Mods" - workshop_path = tmp_path / "workshop" - about_path = workshop_path / "123456" / "About" - about_path.mkdir(parents=True) - (about_path / "About.xml").write_text( - """ - - Workshop Mod - Author - author.workshop -
  • 1.5
  • -
    -""", - encoding="utf-8", - ) - (about_path / "PublishedFileId.txt").write_text("123456", encoding="utf-8") - - providers = create_providers( - PathConfig(local=str(local_path), workshop=str(workshop_path)) - ) - providers_by_id = {provider.provider_id: provider for provider in providers} - - assert len(providers_by_id) == len(providers) - assert providers_by_id["local"]._path == local_path - assert providers_by_id["downloaded"]._path == local_path - - workshop_provider = providers_by_id["steam"] - assert isinstance(workshop_provider, SteamWorkshopModProvider) - assert workshop_provider._path == workshop_path - assert PROVIDER_LABELS[workshop_provider.provider_id] == "Steam Workshop" - - discovered = await workshop_provider.discover("1.5") - assert len(discovered) == 1 - mod = next(iter(discovered.values())) - assert mod.mod_path == workshop_path / "123456" - assert mod.provider_id == "steam" diff --git a/tests/test_services/test_activation_service.py b/tests/test_services/test_activation_service.py deleted file mode 100644 index 4d1cd1c..0000000 --- a/tests/test_services/test_activation_service.py +++ /dev/null @@ -1,389 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveSet, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.services.activation_service import ActivationService -from pxmodrim.core.services.diagnostics_service import DiagnosticsService - - -def _mod( - name: str, - pid: str, - *deps: str, - load_after: tuple[str, ...] = (), - load_before: tuple[str, ...] = (), -) -> AboutXmlMod: - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - provider_id="stub", - valid=True, - about_rules=BaseRules( - load_after=CaseInsensitiveSet(load_after), - load_before=CaseInsensitiveSet(load_before), - dependencies={ - CaseInsensitiveStr(dep): DependencyMod( - name=dep, package_id=CaseInsensitiveStr(dep) - ) - for dep in deps - }, - ), - ) - - -_MODS: dict[str, ListedMod] = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b", "mod.a"), - "uuid-c": _mod("Mod C", "mod.c", "mod.b"), - "uuid-d": _mod("Mod D", "mod.d"), - "uuid-e": _mod("Mod E", "mod.e", "mod.a"), - "uuid-f": _mod("Mod F", "mod.f"), - "uuid-g": _mod("Mod G", "mod.g", "mod.f"), - "uuid-h": _mod( - "Mod H", - "mod.h", - load_after=("mod.b",), - load_before=("mod.a",), - ), - "uuid-i": _mod("Mod I", "mod.i", load_after=("mod.d",)), - "uuid-j": _mod("Mod J", "mod.j", load_before=("mod.a",)), -} - - -@pytest.fixture -def ctx(tmp_path: Path) -> CoreContext: - ctx = CoreContext(AppConfig(), ConfigService(tmp_path)) - ctx._diagnostics_service = DiagnosticsService(ctx) - ctx.load(_MODS, ["uuid-c", "uuid-d", "uuid-a", "uuid-b"]) - ctx.diagnostics_service.rebuild() - return ctx - - -@pytest.fixture -def activation(ctx: CoreContext) -> ActivationService: - return ActivationService(ctx) - - -class TestApply: - def test_enabling_inserts_after_dependency_without_reordering_existing_mods( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert activation.apply(enable=["uuid-f", "uuid-e"]) is True - - expected = ["uuid-c", "uuid-d", "uuid-a", "uuid-e", "uuid-b", "uuid-f"] - assert ctx.active_uuids == expected - assert emitted == [tuple(expected)] - - def test_unconstrained_enable_appends( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-f"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-b", - "uuid-f", - ] - - def test_enabling_mod_with_load_after_rule_inserts_after_target( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-i"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-i", - "uuid-a", - "uuid-b", - ] - - def test_enabling_mod_with_load_before_rule_inserts_before_target( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-j"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-j", - "uuid-a", - "uuid-b", - ] - - def test_enabling_dependency_before_active_dependent( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - ctx.load(_MODS, ["uuid-b", "uuid-c", "uuid-d"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-a"]) is True - - assert ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c", "uuid-d"] - - def test_conflicting_constraints_fall_back_to_appending( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-h"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-b", - "uuid-h", - ] - - def test_batch_enables_respect_dependency_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-g", "uuid-f"]) is True - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-b", - "uuid-f", - "uuid-g", - ] - - def test_enabling_already_active_mod_keeps_its_position( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-c", "uuid-f"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"] - - def test_disabling_removes_and_keeps_remaining_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(disable=["uuid-d", "uuid-a"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-b"] - - def test_unknown_ids_are_ignored( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-missing", "uuid-f"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-a", "uuid-b", "uuid-f"] - - def test_mixed_selection_applies_in_single_emission( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert ( - activation.apply( - enable=["uuid-f", "uuid-e"], - disable=["uuid-a", "uuid-d"], - ) - is True - ) - - expected = ["uuid-c", "uuid-b", "uuid-f", "uuid-e"] - assert ctx.active_uuids == expected - assert emitted == [tuple(expected)] - - def test_uuid_in_both_enable_and_disable_favors_disable( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert ( - activation.apply( - enable=["uuid-a", "uuid-f", "uuid-e"], - disable=["uuid-a", "uuid-f"], - ) - is True - ) - - expected = ["uuid-c", "uuid-d", "uuid-b", "uuid-e"] - assert ctx.active_uuids == expected - assert emitted == [tuple(expected)] - - def test_duplicate_enable_deduped_in_input_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert activation.apply(enable=["uuid-f", "uuid-e", "uuid-f"]) is True - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-a", - "uuid-e", - "uuid-b", - "uuid-f", - ] - - @pytest.mark.parametrize( - ("enable", "disable"), - [ - (["uuid-a", "uuid-c"], []), - ([], ["uuid-e", "uuid-f"]), - (["uuid-missing"], []), - ([], ["uuid-missing"]), - ([], []), - (["uuid-missing"], ["uuid-missing"]), - ], - ) - def test_noop_returns_false_without_emitting( - self, - ctx: CoreContext, - activation: ActivationService, - enable: list[str], - disable: list[str], - ) -> None: - emitted: list[tuple[str, ...]] = [] - ctx.active_state_changed.connect(emitted.append) - - assert activation.apply(enable=enable, disable=disable) is False - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-a", "uuid-b"] - assert emitted == [] - - def test_edges_through_uninstalled_mod_add_no_constraint( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods: dict[str, ListedMod] = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-w": _mod("Mod W", "mod.w", load_after=("ce.absent",)), - "uuid-y": _mod("Mod Y", "mod.y", load_before=("ce.absent",)), - } - ctx.load(mods, ["uuid-a", "uuid-w"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-y"]) is True - - assert ctx.active_uuids == ["uuid-a", "uuid-w", "uuid-y"] - - def test_bounds_ignore_inconsistent_active_order( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - # Active c depends on b but sits before it; X must still land in d..b. - mods = dict(_MODS) - mods["uuid-x"] = _mod("Mod X", "mod.x", "mod.d", load_before=("mod.b",)) - ctx.load(mods, ["uuid-c", "uuid-d", "uuid-a", "uuid-b"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-x"]) is True - - assert ctx.active_uuids == ["uuid-c", "uuid-d", "uuid-x", "uuid-a", "uuid-b"] - - def test_batch_cycle_does_not_unplace_downstream_mods( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods = dict(_MODS) - mods["uuid-p"] = _mod("Mod P", "mod.p", "mod.q") - mods["uuid-q"] = _mod("Mod Q", "mod.q", "mod.p") - mods["uuid-z"] = _mod("Mod Z", "mod.z", "mod.p") - mods["uuid-k"] = _mod("Mod K", "mod.k", "mod.d", load_before=("mod.a",)) - ctx.load(mods, ["uuid-c", "uuid-d", "uuid-a", "uuid-b"]) - ctx.diagnostics_service.rebuild() - - assert activation.apply(enable=["uuid-z", "uuid-k", "uuid-q", "uuid-p"]) - - assert ctx.active_uuids == [ - "uuid-c", - "uuid-d", - "uuid-k", - "uuid-a", - "uuid-b", - "uuid-q", - "uuid-p", - "uuid-z", - ] - - -class TestDependentsOf: - def test_transitive_dependents_in_active_order( - self, activation: ActivationService - ) -> None: - assert activation.dependents_of(["uuid-a"]) == ["uuid-c", "uuid-b"] - - def test_excludes_inputs(self, activation: ActivationService) -> None: - assert activation.dependents_of(["uuid-a", "uuid-b"]) == ["uuid-c"] - - def test_excludes_inactive_dependents( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - assert "uuid-e" not in ctx.active_uuids - - assert "uuid-e" not in activation.dependents_of(["uuid-a"]) - - def test_reflects_graph_after_state_change( - self, activation: ActivationService - ) -> None: - activation.apply(enable=["uuid-e"]) - - assert activation.dependents_of(["uuid-a"]) == ["uuid-c", "uuid-e", "uuid-b"] - - def test_mod_without_dependents(self, activation: ActivationService) -> None: - assert activation.dependents_of(["uuid-d", "uuid-missing"]) == [] - - def test_removing_duplicate_copy_keeps_dependents( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods = {**_MODS, "uuid-a-copy": _mod("Copy A", "mod.a")} - ctx.load(mods, ["uuid-a", "uuid-a-copy", "uuid-b", "uuid-c"]) - ctx.diagnostics_service.rebuild() - - assert activation.dependents_of(["uuid-a-copy"]) == [] - assert activation.apply(disable=["uuid-a-copy"]) - assert ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c"] - assert activation.dependents_of(["uuid-a"]) == ["uuid-b", "uuid-c"] - - def test_enabling_replacement_copy_keeps_dependents( - self, ctx: CoreContext, activation: ActivationService - ) -> None: - mods = {**_MODS, "uuid-a-copy": _mod("Copy A", "mod.a")} - ctx.load(mods, ["uuid-a", "uuid-b", "uuid-c"]) - ctx.diagnostics_service.rebuild() - - assert activation.dependents_of(["uuid-a"], enable=["uuid-a-copy"]) == [] - assert activation.apply(enable=["uuid-a-copy"], disable=["uuid-a"]) - assert ctx.active_uuids == ["uuid-a-copy", "uuid-b", "uuid-c"] - - -def test_sidebar_entries_are_published_before_summary( - ctx: CoreContext, activation: ActivationService -) -> None: - """The summary handler re-applies the sidebar filter, so entries must be fresh.""" - diagnostics = ctx.diagnostics_service - inactive: set[str] = set() - seen_at_summary: list[set[str]] = [] - - def on_entries(entries: list) -> None: - inactive.clear() - inactive.update(next(e for e in entries if e.label == "Inactive").visible_uuids) - - diagnostics.sidebar_entries_changed.connect(on_entries) - diagnostics.diagnostics_summary_changed.connect( - lambda _: seen_at_summary.append(set(inactive)) - ) - - diagnostics.rebuild(ctx.active_uuids) - seen_at_summary.clear() - activation.apply(enable=["uuid-f"]) - - assert seen_at_summary - assert "uuid-f" not in seen_at_summary[-1] diff --git a/tests/test_services/test_diagnostics_databases.py b/tests/test_services/test_diagnostics_databases.py deleted file mode 100644 index ba7608c..0000000 --- a/tests/test_services/test_diagnostics_databases.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -from pxmodrim.core.checker.models import PackageId -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.services.diagnostics_service import ( - DATABASE_MAX_AGE_S, - DiagnosticsService, -) - - -class _FakeDb: - def __init__( - self, - cached: Any, - downloaded: Any, - gate: asyncio.Event, - age_s: float | None = 0.0, - ) -> None: - self.age_s = age_s - self.cached = cached - self.downloaded = downloaded - self.gate = gate - self.downloads = 0 - - def cache_age_s(self) -> float | None: - return self.age_s - - def load_if_exists(self) -> Any: - return self.cached - - async def ensure(self, force: bool = False) -> Any: - self.downloads += 1 - await self.gate.wait() - return self.downloaded - - -def _service(tmp_path: Path, nvw: _FakeDb, uti: _FakeDb) -> DiagnosticsService: - service = DiagnosticsService(CoreContext(AppConfig(), ConfigService(tmp_path))) - service._no_version_warning_service = nvw # type: ignore[assignment] - service._use_this_instead_service = uti # type: ignore[assignment] - return service - - -async def test_initialize_does_not_wait_for_database_download(tmp_path: Path) -> None: - gate = asyncio.Event() - nvw = _FakeDb(set(), {PackageId("a.b")}, gate, age_s=None) - uti = _FakeDb({}, {}, gate, age_s=None) - service = _service(tmp_path, nvw, uti) - events: list[str] = [] - service.background_task_changed.connect(events.append) - - await asyncio.wait_for(service.initialize(), timeout=1) - await asyncio.sleep(0) - assert events == ["Downloading rule databases\u2026"] - - gate.set() - assert service._db_task is not None - await service._db_task - assert events[-1] == "" - assert PackageId("a.b") in service._checker._no_version_warning - - -async def test_cached_databases_are_applied_without_download(tmp_path: Path) -> None: - gate = asyncio.Event() - gate.set() - nvw = _FakeDb({PackageId("x.y")}, set(), gate) - uti = _FakeDb({"1": object()}, {}, gate) - service = _service(tmp_path, nvw, uti) - events: list[str] = [] - service.background_task_changed.connect(events.append) - - await service.initialize() - await asyncio.sleep(0) - - assert PackageId("x.y") in service._checker._no_version_warning - assert nvw.downloads == uti.downloads == 0 - assert events == [] - - -async def test_stale_cache_is_used_immediately_and_refreshed(tmp_path: Path) -> None: - gate = asyncio.Event() - gate.set() - week = DATABASE_MAX_AGE_S - nvw = _FakeDb({PackageId("old.one")}, {PackageId("new.one")}, gate, week + 1) - uti = _FakeDb({"1": object()}, {"2": object()}, gate, 60.0) - service = _service(tmp_path, nvw, uti) - - await service.initialize() - assert PackageId("old.one") in service._checker._no_version_warning - assert service._db_task is not None - await service._db_task - - assert (nvw.downloads, uti.downloads) == (1, 0) - assert PackageId("new.one") in service._checker._no_version_warning - - -async def test_failed_refresh_keeps_cached_data(tmp_path: Path) -> None: - gate = asyncio.Event() - gate.set() - nvw = _FakeDb({PackageId("old.one")}, set(), gate, DATABASE_MAX_AGE_S + 1) - uti = _FakeDb({"1": object()}, {}, gate, 60.0) - service = _service(tmp_path, nvw, uti) - - await service.initialize() - assert service._db_task is not None - await service._db_task - - assert PackageId("old.one") in service._checker._no_version_warning diff --git a/tests/test_services/test_game_launcher.py b/tests/test_services/test_game_launcher.py deleted file mode 100644 index 632010a..0000000 --- a/tests/test_services/test_game_launcher.py +++ /dev/null @@ -1,238 +0,0 @@ -from __future__ import annotations - -import sys -import types -from pathlib import Path - -import pytest - -from pxmodrim.core.config import AppConfig, PathConfig -from pxmodrim.core.constants import RIMWORLD_STEAM_APP_ID, LaunchStrategy -from pxmodrim.core.services.game_launcher import GameLauncher, build_direct_command - - -def test_no_wrapper_appends_args() -> None: - argv, env = build_direct_command("/g/RimWorldLinux", "", "-popupwindow -logfile") - assert argv == ["/g/RimWorldLinux", "-popupwindow", "-logfile"] - assert env == {} - - -def test_wrapper_without_placeholder_is_prefix() -> None: - argv, _ = build_direct_command("/g/rw", "gamemoderun", "-x") - assert argv == ["gamemoderun", "/g/rw", "-x"] - - -def test_placeholder_position_and_env_split() -> None: - argv, env = build_direct_command( - "/g/rw", "MANGOHUD=1 DXVK_HUD=fps mangohud %command% --tail", "-x" - ) - assert argv == ["mangohud", "/g/rw", "-x", "--tail"] - assert env == {"MANGOHUD": "1", "DXVK_HUD": "fps"} - - -@pytest.mark.skipif(sys.platform == "win32", reason="posix quoting") -def test_quoted_args_stay_single_token() -> None: - argv, _ = build_direct_command("/g/rw", "", '-savedatafolder="/tmp/my saves"') - assert argv == ["/g/rw", "-savedatafolder=/tmp/my saves"] - - -def test_option_with_equals_is_not_an_env_var() -> None: - argv, env = build_direct_command("/g/rw", "--cfg=/p wrap", "") - assert argv == ["--cfg=/p", "wrap", "/g/rw"] - assert env == {} - - -def test_unbalanced_quotes_raise() -> None: - with pytest.raises(ValueError): - build_direct_command("/g/rw", "", '"oops') - - -@pytest.mark.parametrize( - ("platform", "name"), - [ - ("linux", "RimWorld"), - ("linux", "RimWorldLinux"), - ("win32", "RimWorld.exe"), - ("win32", "RimWorldWin64.exe"), - ("win32", "RimWorldWin.exe"), - ("darwin", "RimWorld.app"), - ("darwin", "RimWorldMac.app"), - ], -) -def test_find_executable_supports_current_and_legacy_installs( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, platform: str, name: str -) -> None: - monkeypatch.setattr(sys, "platform", platform) - exe = tmp_path / name - if platform == "darwin": - exe.mkdir() - else: - exe.touch() - exe.chmod(0o755) - assert GameLauncher._find_executable(tmp_path) == exe - - -@pytest.mark.parametrize( - ("platform", "current", "legacy"), - [ - ("linux", "RimWorld", "RimWorldLinux"), - ("win32", "RimWorld.exe", "RimWorldWin64.exe"), - ("darwin", "RimWorld.app", "RimWorldMac.app"), - ], -) -def test_find_executable_prefers_waylith_over_leftover_unity_files( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, - platform: str, - current: str, - legacy: str, -) -> None: - monkeypatch.setattr(sys, "platform", platform) - for name in (legacy, current): - exe = tmp_path / name - if platform == "darwin": - exe.mkdir() - else: - exe.touch() - exe.chmod(0o755) - assert GameLauncher._find_executable(tmp_path) == tmp_path / current - - -@pytest.mark.skipif(sys.platform == "win32", reason="POSIX executable permissions") -def test_linux_lookup_skips_non_executable_waylith_file( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setattr(sys, "platform", "linux") - (tmp_path / "RimWorld").touch() - legacy = tmp_path / "RimWorldLinux" - legacy.touch() - legacy.chmod(0o755) - assert GameLauncher._find_executable(tmp_path) == legacy - - -class _Proc: - def __init__(self, name: str, status: str = "running", pid: int = 1) -> None: - self.pid = pid - self.info = {"name": name, "status": status} - - -def _patch_procs(monkeypatch: pytest.MonkeyPatch, procs: list[list[_Proc]]) -> None: - snapshots = iter(procs) - last: list[_Proc] = [] - - def fake_iter(_attrs: object = None) -> list[_Proc]: - nonlocal last - last = next(snapshots, last) - return last - - monkeypatch.setattr( - "pxmodrim.core.services.game_launcher.psutil.process_iter", fake_iter - ) - - -@pytest.mark.parametrize( - "name", - [ - "RimWorld", - "RimWorld.exe", - "RimWorldLinux", - "RimWorldWin64.exe", - "RimWorldWin.exe", - "RimWorld by Ludeon Studios", - ], -) -def test_recognizes_current_and_legacy_game_processes( - monkeypatch: pytest.MonkeyPatch, name: str -) -> None: - _patch_procs(monkeypatch, [[_Proc(name), _Proc("bash")]]) - assert GameLauncher.is_running() is True - - -@pytest.mark.parametrize("name", ["RimWorld", "RimWorldLinux"]) -def test_zombie_game_process_is_not_running( - monkeypatch: pytest.MonkeyPatch, name: str -) -> None: - _patch_procs(monkeypatch, [[_Proc(name, "zombie"), _Proc("bash")]]) - assert GameLauncher.is_running() is False - - -@pytest.mark.asyncio -async def test_wait_for_exit_returns_once_process_disappears( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_EXIT", 0) - _patch_procs(monkeypatch, [[_Proc("RimWorldLinux")], [_Proc("RimWorldLinux")], []]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - assert await launcher.wait_for_exit() is None - - -@pytest.mark.asyncio -async def test_steam_launch_passes_encoded_args( - monkeypatch: pytest.MonkeyPatch, -) -> None: - opened: list[str] = [] - monkeypatch.setattr( - "pxmodrim.core.services.game_launcher.webbrowser.open", opened.append - ) - cfg = AppConfig(paths=PathConfig(game="/g"), launch_args="-savedatafolder=/a b") - launcher = GameLauncher(types.SimpleNamespace(config=cfg)) # type: ignore[arg-type] - ok, _ = await launcher.launch(LaunchStrategy.STEAM) - assert ok - assert opened == [ - f"steam://run/{RIMWORLD_STEAM_APP_ID}//-savedatafolder%3D%2Fa%20b/" - ] - - -class _Popen: - def __init__(self, code: int | None) -> None: - self.code = code - - def poll(self) -> int | None: - return self.code - - -@pytest.mark.asyncio -async def test_wait_for_exit_returns_spawned_exit_code( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_EXIT", 0) - _patch_procs(monkeypatch, [[_Proc("RimWorldLinux")], []]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - launcher._proc = _Popen(7) # type: ignore[assignment] - assert await launcher.wait_for_exit() == 7 - - -@pytest.mark.asyncio -async def test_wait_for_start_true_once_process_appears( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_START", 0) - _patch_procs(monkeypatch, [[], [], [_Proc("RimWorldLinux")]]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - assert await launcher.wait_for_start(5) is True - - -@pytest.mark.asyncio -async def test_wait_for_start_false_when_spawned_process_died( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_START", 0) - _patch_procs(monkeypatch, [[]]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - launcher._proc = _Popen(1) # type: ignore[assignment] - assert await launcher.wait_for_start(5) is False - - -@pytest.mark.asyncio -async def test_stale_game_process_from_before_launch_is_ignored( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("pxmodrim.core.services.game_launcher._POLL_EXIT", 0) - stale = _Proc("RimWorldLinux", pid=10) - fresh = _Proc("RimWorldLinux", pid=20) - _patch_procs(monkeypatch, [[stale], [stale, fresh], [stale, fresh], [stale]]) - launcher = GameLauncher(types.SimpleNamespace(config=AppConfig())) # type: ignore[arg-type] - launcher._baseline = frozenset({10}) - assert await launcher.wait_for_start(5) is True - await launcher.wait_for_exit() - assert GameLauncher.is_running() is True diff --git a/tests/test_services/test_sort_service.py b/tests/test_services/test_sort_service.py deleted file mode 100644 index e9ec004..0000000 --- a/tests/test_services/test_sort_service.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.services.sort_service import SortService -from pxmodrim.core.sort.models import PackageId - - -def _mod(name: str, pid: str = "") -> AboutXmlMod: - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid or name.lower().replace(" ", ".")), - provider_id="stub", - valid=True, - ) - - -def _mod_with_deps(name: str, pid: str, deps: dict[str, str]) -> AboutXmlMod: - """Create an AboutXmlMod with the given dependencies. - - ``deps`` maps dependency package-id → dependency name. - """ - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - provider_id="stub", - valid=True, - about_rules=BaseRules( - dependencies={ - CaseInsensitiveStr(dep_pid): DependencyMod( - name=dep_name, - package_id=CaseInsensitiveStr(dep_pid), - ) - for dep_pid, dep_name in deps.items() - } - ), - ) - - -def _mod_with_alt_deps(name: str, pid: str, deps: dict[str, set[str]]) -> AboutXmlMod: - """Create an AboutXmlMod with deps that have alternative package IDs. - - ``deps`` maps dependency package-id → set of alternative package IDs. - """ - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - provider_id="stub", - valid=True, - about_rules=BaseRules( - dependencies={ - CaseInsensitiveStr(dep_pid): DependencyMod( - name=f"Dep of {name}", - package_id=CaseInsensitiveStr(dep_pid), - alternative_package_ids={CaseInsensitiveStr(a) for a in alts}, - ) - for dep_pid, alts in deps.items() - } - ), - ) - - -@pytest.fixture -def ctx() -> CoreContext: - return CoreContext(AppConfig()) - - -@pytest.fixture -def sort_service(ctx: CoreContext) -> SortService: - return SortService(ctx, MagicMock()) - - -class TestSortActiveMods: - async def test_duplicate_inactive_copy_does_not_replace_active_uuid( - self, ctx: CoreContext - ) -> None: - active_mod = _mod("Active Copy", "shared.package") - inactive_mod = _mod("Inactive Copy", "shared.package") - ctx.load( - { - "uuid-active": active_mod, - "uuid-inactive": inactive_mod, - }, - active_uuids=["uuid-active"], - ) - diagnostics = MagicMock() - diagnostics.active_mods_by_pid = {PackageId("shared.package"): active_mod} - service = SortService(ctx, diagnostics) - - with patch( - "pxmodrim.core.services.sort_service.topological_sort", - return_value=[PackageId("shared.package")], - ): - result = await service.sort_active_mods() - - assert result == ["uuid-active"] - - -class TestResolveMissingDependencies: - def test_no_dependencies(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == [] - - def test_missing_dep_exists_as_inactive(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=["uuid-a"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a"}) - assert result == ["uuid-b"] - - def test_missing_dep_not_in_all_mods(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - }, - active_uuids=["uuid-a"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a"}) - assert result == [] - - def test_missing_dep_already_active(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == [] - - def test_dep_satisfied_via_alternative_pid(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_alt_deps( - "Mod A", - "mod.a", - {"mod.b": {"mod.c"}}, - ), - "uuid-c": _mod("Mod C", "mod.c"), - }, - active_uuids=["uuid-a", "uuid-c"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-c"}) - assert result == [] - - def test_non_about_xml_mod_in_active_set_skipped( - self, sort_service: SortService - ) -> None: - folder_mod = ListedMod(name="Folder Mod", provider_id="stub") - sort_service._ctx.load( - { - "uuid-folder": folder_mod, - "uuid-a": _mod("Mod A", "mod.a"), - }, - active_uuids=["uuid-folder", "uuid-a"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-folder", "uuid-a"}) - assert result == [] - - def test_no_duplicates_when_two_mods_share_missing_dep( - self, sort_service: SortService - ) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.c": "Mod C"}), - "uuid-b": _mod_with_deps("Mod B", "mod.b", {"mod.c": "Mod C"}), - "uuid-c": _mod("Mod C", "mod.c"), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == ["uuid-c"] - - def test_empty_active_set_returns_empty(self, sort_service: SortService) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod_with_deps("Mod A", "mod.a", {"mod.b": "Mod B"}), - "uuid-b": _mod("Mod B", "mod.b"), - }, - active_uuids=[], - ) - result = sort_service.resolve_missing_dependencies(set()) - assert result == [] - - def test_mod_with_no_deps_does_not_add_to_result( - self, sort_service: SortService - ) -> None: - sort_service._ctx.load( - { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod_with_deps("Mod B", "mod.b", {"mod.a": "Mod A"}), - }, - active_uuids=["uuid-a", "uuid-b"], - ) - result = sort_service.resolve_missing_dependencies({"uuid-a", "uuid-b"}) - assert result == [] diff --git a/tests/test_services/test_update_service.py b/tests/test_services/test_update_service.py deleted file mode 100644 index 8bb18b7..0000000 --- a/tests/test_services/test_update_service.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import httpx -import pytest - -from pxmodrim.core.services.update_service import ( - UpdateCheckError, - UpdateService, - is_newer, - parse_version, -) - - -@pytest.mark.parametrize( - ("tag", "current", "expected"), - [ - ("v0.2.0", "0.1.0", True), - ("v0.1.0", "0.1.0", False), - ("v0.1.0", "0.2.0", False), - ("v0.10.0", "0.9.0", True), - ("v1.0", "1.0.0", False), - ("v1.0.1", "1.0", True), - ("v0.2.0-beta.1", "0.1.0", True), - ("nightly", "0.1.0", False), - ("v0.2.0", "unknown", False), - ("v0.2.0", "0.2.0-rc1", True), - ("v0.2.0-rc1", "0.2.0", False), - ("v0.2.0-rc2", "0.2.0-rc1", True), - ("v0.2.0-rc1", "0.2.0-rc1", False), - ("v0.2.0-rc1", "0.1.9", True), - ("v0.1.9", "0.2.0-rc1", False), - ("v0.2.0+build5", "0.2.0", False), - ], -) -def test_is_newer(tag: str, current: str, expected: bool) -> None: - assert is_newer(tag, current) is expected - - -def test_parse_version_rejects_non_numeric() -> None: - assert parse_version("latest") is None - assert parse_version(" v1.2.3 ") == (1, 2, 3) - - -def _service(transport: httpx.MockTransport, current: str = "0.1.0") -> UpdateService: - return UpdateService(current, transport=transport) - - -def _json_transport(payload: object, status: int = 200) -> httpx.MockTransport: - return httpx.MockTransport(lambda request: httpx.Response(status, json=payload)) - - -_RELEASE = { - "tag_name": "v0.2.0", - "html_url": "https://example.test/r/v0.2.0", - "name": "Second", - "body": "notes", -} - - -async def test_check_returns_newer_release() -> None: - release = await _service(_json_transport(_RELEASE)).check() - - assert release is not None - assert release.tag == "v0.2.0" - assert release.name == "Second" - assert release.notes == "notes" - assert release.url == "https://example.test/r/v0.2.0" - - -async def test_check_returns_none_when_not_newer() -> None: - assert await _service(_json_transport(_RELEASE), "0.2.0").check() is None - - -async def test_check_offers_release_over_its_rc() -> None: - service = _service(_json_transport(_RELEASE), "0.2.0-rc1") - - release = await service.check() - - assert release is not None - assert release.tag == "v0.2.0" - - -async def test_check_wraps_http_status_error() -> None: - with pytest.raises(UpdateCheckError): - await _service(_json_transport({}, status=500)).check() - - -async def test_check_wraps_transport_error() -> None: - def fail(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("down", request=request) - - with pytest.raises(UpdateCheckError): - await _service(httpx.MockTransport(fail)).check() - - -async def test_check_wraps_non_json_body() -> None: - transport = httpx.MockTransport(lambda request: httpx.Response(200, text="")) - - with pytest.raises(UpdateCheckError): - await _service(transport).check() - - -@pytest.mark.parametrize("payload", [[], None, "tag", 3]) -async def test_check_rejects_non_object_json(payload: object) -> None: - with pytest.raises(UpdateCheckError): - await _service(_json_transport(payload)).check() - - -@pytest.mark.parametrize( - "payload", - [ - {}, - {"tag_name": "v0.2.0"}, - {"html_url": "https://example.test/r"}, - {"tag_name": "", "html_url": "https://example.test/r"}, - ], -) -async def test_check_rejects_missing_fields(payload: dict[str, str]) -> None: - with pytest.raises(UpdateCheckError): - await _service(_json_transport(payload)).check() diff --git a/tests/test_sort/test_tier_order.py b/tests/test_sort/test_tier_order.py deleted file mode 100644 index 1967dfb..0000000 --- a/tests/test_sort/test_tier_order.py +++ /dev/null @@ -1,254 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.checker.graph import ConstraintGraph -from pxmodrim.core.checker.sort import topological_sort -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveSet, - CaseInsensitiveStr, -) -from pxmodrim.core.sort.config import SortSettings, Tier, TierConfig -from pxmodrim.core.sort.tiers import ( - find_cycle, - get_deps_recursive, - get_reverse_deps_recursive, -) - -PackageId = CaseInsensitiveStr - - -def _make_mod(package_id: str) -> AboutXmlMod: - m = AboutXmlMod() - m.package_id = CaseInsensitiveStr(package_id) - m.name = package_id - m._mod_path = None - return m - - -def _graph( - mods: dict[PackageId, AboutXmlMod], - ordered_pids: list[PackageId], - settings: SortSettings | None = None, -) -> ConstraintGraph: - graph = ConstraintGraph() - graph.build(mods, ordered_pids, settings or SortSettings()) - return graph - - -def _active_dict(*mods: AboutXmlMod) -> dict[PackageId, AboutXmlMod]: - return {PackageId(m.package_id): m for m in mods} - - -def _sort( - mods: dict[PackageId, AboutXmlMod], - tier_config: TierConfig | None = None, -) -> list[PackageId]: - ordered_pids = list(mods.keys()) - settings = SortSettings( - use_community_rules=False, - use_alternative_package_ids=False, - tier_config=tier_config or TierConfig.default(), - ) - graph = _graph(mods, ordered_pids, settings) - return topological_sort(mods, graph, settings) - - -def _assert_order( - result: list[PackageId], - tier: Tier, - expected: list[str], -): - pids_in_tier = [pid for pid in result if pid in expected] - assert [str(p) for p in pids_in_tier] == expected, ( - f"TIER_{tier.value} order mismatch\n" - f" expected: {expected}\n" - f" got: {[str(p) for p in pids_in_tier]}" - ) - - -class TestTier0Order: - def test_follows_config_order(self): - mods = _active_dict( - _make_mod("brrainz.harmony"), - _make_mod("zetrith.prepatcher"), - _make_mod("ludeon.rimworld"), - _make_mod("ludeon.rimworld.royalty"), - _make_mod("ludeon.rimworld.ideology"), - _make_mod("ludeon.rimworld.biotech"), - _make_mod("ludeon.rimworld.anomaly"), - _make_mod("ludeon.rimworld.odyssey"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_0, - [ - "zetrith.prepatcher", - "brrainz.harmony", - "ludeon.rimworld", - "ludeon.rimworld.royalty", - "ludeon.rimworld.ideology", - "ludeon.rimworld.biotech", - "ludeon.rimworld.anomaly", - "ludeon.rimworld.odyssey", - ], - ) - - def test_partial_active(self): - mods = _active_dict( - _make_mod("ludeon.rimworld.biotech"), - _make_mod("zetrith.prepatcher"), - _make_mod("ludeon.rimworld.royalty"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_0, - [ - "zetrith.prepatcher", - "ludeon.rimworld.royalty", - "ludeon.rimworld.biotech", - ], - ) - - def test_with_deps_within_tier(self): - mods = _active_dict( - _make_mod("brrainz.harmony"), - _make_mod("zetrith.prepatcher"), - ) - mods[PackageId("brrainz.harmony")].about_rules.load_after = CaseInsensitiveSet( - ["zetrith.prepatcher"] - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_0, - [ - "zetrith.prepatcher", - "brrainz.harmony", - ], - ) - - -class TestTier1Order: - def test_follows_config_order(self): - mods = _active_dict( - _make_mod("unlimitedhugs.hugslib"), - _make_mod("adaptive.storage.framework"), - _make_mod("aoba.framework"), - _make_mod("smashphil.vehicleframework"), - _make_mod("vanillaexpanded.backgrounds"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_1, - [ - "adaptive.storage.framework", - "aoba.framework", - "smashphil.vehicleframework", - "unlimitedhugs.hugslib", - "vanillaexpanded.backgrounds", - ], - ) - - def test_partial_active(self): - mods = _active_dict( - _make_mod("smashphil.vehicleframework"), - _make_mod("adaptive.storage.framework"), - _make_mod("vanillaexpanded.backgrounds"), - ) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_1, - [ - "adaptive.storage.framework", - "smashphil.vehicleframework", - "vanillaexpanded.backgrounds", - ], - ) - - def test_with_deps_within_tier(self): - mods = _active_dict( - _make_mod("aoba.exosuit.framework"), - _make_mod("aoba.framework"), - ) - mods[ - PackageId("aoba.exosuit.framework") - ].about_rules.load_after = CaseInsensitiveSet(["aoba.framework"]) - result = _sort(mods) - _assert_order( - result, - Tier.TIER_1, - [ - "aoba.framework", - "aoba.exosuit.framework", - ], - ) - - -class TestTierBoundaries: - def test_tiers_are_ordered(self): - mods = _active_dict( - _make_mod("some.two.mod"), - _make_mod("brrainz.harmony"), - _make_mod("adaptive.storage.framework"), - _make_mod("some.three.mod"), - ) - tc = TierConfig( - tier_0=(PackageId("brrainz.harmony"),), - tier_1=(PackageId("adaptive.storage.framework"),), - tier_3=(PackageId("some.three.mod"),), - ) - result = _sort(mods, tier_config=tc) - order = [str(p) for p in result] - assert order.index("brrainz.harmony") < order.index( - "adaptive.storage.framework" - ), "TIER_0 before TIER_1" - assert order.index("adaptive.storage.framework") < order.index( - "some.two.mod" - ), "TIER_1 before TIER_2" - assert order.index("some.two.mod") < order.index("some.three.mod"), ( - "TIER_2 before TIER_3" - ) - - -class TestNonConfigMods: - def test_unknown_mods_after_config_mods(self): - mods = _active_dict( - _make_mod("unknown.first"), - _make_mod("zetrith.prepatcher"), - _make_mod("unknown.second"), - _make_mod("brrainz.harmony"), - ) - tc = TierConfig( - tier_0=(PackageId("zetrith.prepatcher"), PackageId("brrainz.harmony")), - tier_1=(), - tier_3=(), - ) - result = _sort(mods, tier_config=tc) - order = [str(p) for p in result] - tier0_pids = [ - p for p in order if p in ("zetrith.prepatcher", "brrainz.harmony") - ] - assert tier0_pids == ["zetrith.prepatcher", "brrainz.harmony"], ( - f"config mods in wrong order: {tier0_pids}" - ) - - -def test_tier_graph_algorithms_handle_long_chains(): - nodes = [PackageId(f"mod.{index}") for index in range(5000)] - deps = {nodes[index]: {nodes[index + 1]} for index in range(len(nodes) - 1)} - reverse_deps = {nodes[index + 1]: {nodes[index]} for index in range(len(nodes) - 1)} - - assert get_deps_recursive(nodes[0], deps) == set(nodes[1:]) - assert get_reverse_deps_recursive(nodes[-1], reverse_deps) == set(nodes[:-1]) - assert find_cycle(set(nodes), deps) is None - - deps[nodes[-1]] = {nodes[0]} - cycle = find_cycle(set(nodes), deps) - assert cycle is not None - assert len(cycle) == len(nodes) + 1 - assert cycle[0] == cycle[-1] - assert set(cycle[:-1]) == set(nodes) diff --git a/tests/test_support/test_report.py b/tests/test_support/test_report.py deleted file mode 100644 index 1d340c1..0000000 --- a/tests/test_support/test_report.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -import io -import urllib.error -from pathlib import Path -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest - -from pxmodrim.core.config import AppConfig, PathConfig -from pxmodrim.core.models.metadata.structures import AboutXmlMod -from pxmodrim.core.support.report import ( - PasteUploader, - ReportUploadError, - build_report, - get_app_version, - read_log_tail, - redact, -) - - -class TestRedact: - def test_redacts_home_dir_and_username_in_user_paths(self) -> None: - raw = "User alice at /home/alice/.config/log.txt and /mnt/home/alice/x" - result = redact(raw, home_dir="/home/alice", username="alice") - assert result == "User alice at ~/.config/log.txt and /mnt/home//x" - - def test_redacts_windows_style_paths(self) -> None: - raw = r"Loaded C:\Users\Bob\AppData\Local\pxmodrim\test.log" - result = redact(raw, home_dir=r"C:\Users\Bob", username="Bob") - assert result == r"Loaded ~\AppData\Local\pxmodrim\test.log" - - def test_redacts_forward_slash_windows_path(self) -> None: - raw = "Loaded C:/Users/Bob/AppData/test.log" - result = redact(raw, home_dir=r"C:\Users\Bob", username="Bob") - assert result == "Loaded ~/AppData/test.log" - - def test_home_dir_respects_path_boundary(self) -> None: - raw = "/home/bob/a and /home/bobby/b and /home/bob" - result = redact(raw, home_dir="/home/bob", username="bob") - assert result == "~/a and /home/bobby/b and ~" - - def test_redacts_username_on_removable_media_mounts(self) -> None: - raw = "/run/media/alice/SD/mods and /media/alice/x and /media/alicia/y" - result = redact(raw, home_dir="/home/alice", username="alice") - assert ( - result - == "/run/media//SD/mods and /media//x and /media/alicia/y" - ) - - def test_redacts_doubled_backslashes_from_repr(self) -> None: - raw = repr(r"C:\Users\Bob\AppData") + " and " + repr(r"D:\Users\Bob\mods") - result = redact(raw, home_dir=r"C:\Users\Bob", username="Bob") - assert "Bob" not in result - assert result.startswith("'~") - - def test_short_username_redacted_in_other_user_paths(self) -> None: - raw = r"ed saw D:\Users\ed\mods and /Users/ED/x but not /Users/edgar" - result = redact(raw, home_dir="/home/ed", username="ed") - expected = ( - r"ed saw D:\Users\\mods and /Users//x but not /Users/edgar" - ) - assert result == expected - - def test_common_word_username_does_not_corrupt_package_ids(self) -> None: - raw = "Core (ludeon.rimworld) from /opt/rimworld; rimworld started" - result = redact(raw, home_dir="/home/rimworld", username="rimworld") - assert result == raw - - -class TestReadLogTail: - def test_missing_file_returns_notice(self, tmp_path: Path) -> None: - missing = tmp_path / "does_not_exist.log" - content = read_log_tail(missing) - assert "" in content - - def test_empty_file_returns_notice(self, tmp_path: Path) -> None: - empty = tmp_path / "empty.log" - empty.touch() - content = read_log_tail(empty) - assert "" in content - - def test_reads_small_file_completely(self, tmp_path: Path) -> None: - log_file = tmp_path / "app.log" - text = "Line 1\nLine 2\nLine 3\n" - log_file.write_text(text, encoding="utf-8") - content = read_log_tail(log_file) - assert content == text - - def test_parses_loguru_serialized_json_lines(self, tmp_path: Path) -> None: - log_file = tmp_path / "json.log" - raw_json = ( - '{"text": "2026-09-28 | INFO | Loaded mods\\n", "record": {"id": 1}}\n' - "Plain log line\n" - '{"text": "2026-09-28 | INFO | Ready\\n", "record": {"id": 2}}\n' - ) - log_file.write_text(raw_json, encoding="utf-8") - content = read_log_tail(log_file) - expected = ( - "2026-09-28 | INFO | Loaded mods\n" - "Plain log line\n" - "2026-09-28 | INFO | Ready\n" - ) - assert content == expected - assert '{"text"' not in content - - def test_truncates_large_file_and_adds_banner(self, tmp_path: Path) -> None: - log_file = tmp_path / "large.log" - lines = [ - f"Log record number {i:04d} with some extra padding" for i in range(200) - ] - log_file.write_text("\n".join(lines), encoding="utf-8") - - # Cap to 200 bytes - content = read_log_tail(log_file, max_bytes=200) - assert "[... truncated, showing last " in content - assert " of " in content - assert "Log record number 0199" in content - assert "Log record number 0001" not in content - - -def test_get_app_version_fallback_unknown() -> None: - from importlib.metadata import PackageNotFoundError - - with patch( - "pxmodrim.core.support.report.version", side_effect=PackageNotFoundError - ): - assert get_app_version() == "unknown" - - -class MockContext: - def __init__( - self, - game_version: str = "1.5.4243", - paths: PathConfig | None = None, - mods: dict[str, Any] | None = None, - active_uuids: list[str] | None = None, - ) -> None: - self.game_version = game_version - self.config = AppConfig(paths=paths or PathConfig()) - self.all_mods = mods or {} - self.active_uuids = active_uuids or [] - - -class TestBuildReport: - def test_report_contains_all_sections_and_active_mods_in_order( - self, tmp_path: Path - ) -> None: - mod1 = AboutXmlMod() - mod1.name = "Harmony" - mod1.package_id = "brrainz.harmony" # type: ignore[assignment] - - mod2 = AboutXmlMod() - mod2.name = "Core" - mod2.package_id = "ludeon.rimworld" # type: ignore[assignment] - - mod3 = AboutXmlMod() - mod3.name = "Royalty" - mod3.package_id = "ludeon.rimworld.royalty" # type: ignore[assignment] - - mods = {"u1": mod1, "u2": mod2, "u3": mod3} - # Specify load order: u2 (Core), then u1 (Harmony), then u3 (Royalty) - active = ["u2", "u1", "u3"] - - home = tmp_path / "home" / "testuser" - home.mkdir(parents=True, exist_ok=True) - paths = PathConfig( - game=str(home / "Games" / "RimWorld"), - config_folder=str(home / ".config" / "unity3d"), - local=str(home / "Games" / "RimWorld" / "Mods"), - ) - ctx = MockContext( - game_version="1.5.4243", - paths=paths, - mods=mods, - active_uuids=active, - ) - log_path = tmp_path / "app.log" - - log_path.write_text( - "INFO | Loaded /home/testuser/mods for testuser\n", encoding="utf-8" - ) - - report = build_report( - ctx, - log_path=log_path, - home_dir=str(home), - username="testuser", - ) - - # Verify sections - assert "=== PxModRim System Report ===" in report - assert "=== RimWorld Configuration ===" in report - assert "=== Mod Collection ===" in report - assert "=== Active Mods (Load Order) ===" in report - assert "=== Application Log ===" in report - - # Verify system info - assert f"App Version: {get_app_version()}" in report - assert "Python Version:" in report - assert "OS:" in report - - # Verify game version and paths are redacted - assert "Game Version: 1.5.4243" in report - assert "~/Games/RimWorld" in report.replace("\\", "/") - assert "~/.config/unity3d" in report.replace("\\", "/") - assert str(home) not in report - - # Verify mod counts - assert "Total Mods: 3" in report - assert "Active Mods: 3" in report - - # Verify active mods in load order - expected_order = ( - " 1. Core (ludeon.rimworld)\n" - " 2. Harmony (brrainz.harmony)\n" - " 3. Royalty (ludeon.rimworld.royalty)" - ) - assert expected_order in report - - # Username is redacted only in user-directory paths - assert "Loaded /home//mods for testuser" in report - - -class TestPasteUploader: - def test_upload_success(self) -> None: - uploader = PasteUploader(endpoint="https://paste.rs/test") - mock_response = MagicMock() - mock_response.status = 201 - mock_response.read.return_value = b"https://paste.rs/abc123\n" - mock_response.__enter__.return_value = mock_response - mock_response.__exit__.return_value = None - with patch( - "urllib.request.urlopen", return_value=mock_response - ) as mock_urlopen: - url = uploader.upload("Report text payload") - assert url == "https://paste.rs/abc123" - - mock_urlopen.assert_called_once() - req = mock_urlopen.call_args[0][0] - assert req.get_header("User-agent").startswith("PxModRim/") - assert req.get_header("Content-type") == "text/plain; charset=utf-8" - assert req.data == b"Report text payload" - - def test_upload_empty_text_raises_error(self) -> None: - uploader = PasteUploader() - with pytest.raises(ReportUploadError, match="Report text is empty"): - uploader.upload(" ") - - def test_upload_non_2xx_status_raises_error(self) -> None: - uploader = PasteUploader() - mock_response = MagicMock() - mock_response.status = 500 - mock_response.__enter__.return_value = mock_response - mock_response.__exit__.return_value = None - with ( - patch("urllib.request.urlopen", return_value=mock_response), - pytest.raises(ReportUploadError, match="HTTP status 500"), - ): - uploader.upload("Report content") - - def test_upload_http_error_raises_report_upload_error(self) -> None: - uploader = PasteUploader() - http_error = urllib.error.HTTPError( - url="https://paste.rs/", - code=403, - msg="Forbidden", - hdrs={}, # type: ignore[arg-type] - fp=io.BytesIO(b""), - ) - with ( - patch("urllib.request.urlopen", side_effect=http_error), - pytest.raises(ReportUploadError, match="HTTP 403 Forbidden"), - ): - uploader.upload("Report content") - - def test_upload_non_url_response_raises_error(self) -> None: - uploader = PasteUploader() - mock_response = MagicMock() - mock_response.status = 200 - mock_response.read.return_value = b"Something unexpected went wrong" - mock_response.__enter__.return_value = mock_response - mock_response.__exit__.return_value = None - with ( - patch("urllib.request.urlopen", return_value=mock_response), - pytest.raises(ReportUploadError, match="valid URL"), - ): - uploader.upload("Report content") - - def test_upload_network_error_raises_error(self) -> None: - uploader = PasteUploader() - url_error = urllib.error.URLError("Connection refused") - with ( - patch("urllib.request.urlopen", side_effect=url_error), - pytest.raises(ReportUploadError, match="network error"), - ): - uploader.upload("Report content") - - def test_upload_timeout_raises_error(self) -> None: - uploader = PasteUploader() - with ( - patch("urllib.request.urlopen", side_effect=TimeoutError()), - pytest.raises(ReportUploadError, match="timed out"), - ): - uploader.upload("Report content") diff --git a/tests/test_ui/test_dialog_chrome.py b/tests/test_ui/test_dialog_chrome.py deleted file mode 100644 index 2539c4b..0000000 --- a/tests/test_ui/test_dialog_chrome.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtWidgets import QApplication, QDialog, QMessageBox, QPushButton - -from pxmodrim.ui.components.dialog_chrome import install_dialog_chrome - -_SB = QMessageBox.StandardButton - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def _click_close(dialog: QDialog) -> None: - install_dialog_chrome(dialog) - button = dialog.findChild(QPushButton, "dialogCloseBtn") - assert button is not None - button.click() - - -@pytest.mark.parametrize( - "buttons", [_SB.Yes | _SB.No | _SB.Cancel, _SB.Save | _SB.Discard | _SB.Cancel] -) -def test_message_box_close_yields_cancel(qapp: QApplication, buttons) -> None: - box = QMessageBox() - box.setStandardButtons(buttons) - _click_close(box) - assert box.result() == int(_SB.Cancel) - - -def test_plain_dialog_close_rejects(qapp: QApplication) -> None: - dialog = QDialog() - _click_close(dialog) - assert dialog.result() == QDialog.DialogCode.Rejected diff --git a/tests/test_ui/test_downloads_model.py b/tests/test_ui/test_downloads_model.py deleted file mode 100644 index 2291936..0000000 --- a/tests/test_ui/test_downloads_model.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from pxmodrim.core.downloads import DownloadItemStatus, DownloadResult -from pxmodrim.ui.plugins.downloads.model import DownloadsModel - - -def _status(pid: str, status: str, done: int = 0, total: int = 0, error: str = ""): - return DownloadItemStatus(pid, status, done, total, error) - - -def _states(model: DownloadsModel) -> dict[str, str]: - return {r.pid: r.state for r in model._rows} - - -def test_states_follow_item_events_and_cancel_leftovers() -> None: - model = DownloadsModel() - model.begin(["1", "2", "3", "4", "5"], {"1": "One"}) - model.apply(_status("1", "downloading", 0, 0)) - model.apply(_status("2", "downloading", 5, 10)) - model.apply(_status("3", "downloading", 10, 10)) - model.apply(_status("4", "error", error="boom")) - model._flush() - assert _states(model) == { - "1": "unchanged", - "2": "downloading", - "3": "updated", - "4": "failed", - "5": "queued", - } - model.finish(DownloadResult(succeeded=["1", "3"], failed=["4"], changed=["3"])) - assert _states(model)["2"] == "cancelled" - assert _states(model)["5"] == "cancelled" - assert (model.updated, model.unchanged, model.failed, model.active) == (1, 1, 3, 0) - assert model.failed_ids() == ["2", "4", "5"] - - -def test_filter_limits_visible_rows() -> None: - model = DownloadsModel() - model.begin(["1", "2"], {"1": "One"}) - model.apply(_status("2", "error", error="x")) - model._flush() - model.setFilter("failed") - assert model.rowCount() == 1 - assert model.data(model.index(0), DownloadsModel._TitleRole) == "2" - model.setFilter("all") - assert model.rowCount() == 2 diff --git a/tests/test_ui/test_main_window.py b/tests/test_ui/test_main_window.py deleted file mode 100644 index 6c28f50..0000000 --- a/tests/test_ui/test_main_window.py +++ /dev/null @@ -1,314 +0,0 @@ -from __future__ import annotations - -import asyncio -from types import SimpleNamespace -from typing import cast - -import pytest -from PySide6.QtGui import QCloseEvent -from PySide6.QtWidgets import QMessageBox - -from pxmodrim.ui.window import main_window as main_window_module -from pxmodrim.ui.window.main_window import MainWindow, UnsavedChangesDialog - - -def test_view_shortcuts_wrap_and_ignore_invalid_indexes() -> None: - selections: list[int] = [] - - class Stack: - index = 0 - - def count(self) -> int: - return 3 - - def currentIndex(self) -> int: - return self.index - - stack = Stack() - - class Rail: - def set_current(self, index: int) -> None: - selections.append(index) - - views = [ - SimpleNamespace(view_id=v, open_route=lambda path: None) - for v in ("a", "b", "c") - ] - - class Window: - _stack = stack - _rail = Rail() - _views = views - - def __init__(self) -> None: - self._app_ctx = SimpleNamespace( - navigate=lambda url: MainWindow._open_route(cast(MainWindow, self), url) - ) - - def _select_view(self, index: int) -> None: - MainWindow._select_view(cast(MainWindow, self), index) - - def _show_view(self, index: int) -> None: - MainWindow._show_view(cast(MainWindow, self), index) - - def _on_rail_tab_changed(self, index: int) -> None: - stack.index = index - - window = cast(MainWindow, Window()) - MainWindow._cycle_view(window, -1) - assert selections == [2] - assert stack.index == 2 - MainWindow._cycle_view(window, 1) - assert selections == [2, 0] - assert stack.index == 0 - MainWindow._select_view(window, 3) - assert selections == [2, 0] - - -def test_route_selects_the_view_then_hands_it_the_path() -> None: - opened: list[tuple[str, ...]] = [] - shown: list[int] = [] - - def view(view_id: str) -> SimpleNamespace: - return SimpleNamespace( - view_id=view_id, - open_route=lambda path, v=view_id: opened.append((v, *path)), - ) - - window = cast( - MainWindow, - SimpleNamespace( - _views=[view("mods"), view("workshop")], _show_view=shown.append - ), - ) - MainWindow._open_route(window, "modrim://workshop/mod/42") - MainWindow._open_route(window, "modrim://missing/x") - MainWindow._open_route(window, "workshop") - assert shown == [1] - assert opened == [("workshop", "mod", "42")] - - -def test_settings_route_opens_settings_without_switching_views() -> None: - shown: list[int] = [] - opened: list[bool] = [] - window = cast( - MainWindow, - SimpleNamespace( - _views=[], - _show_view=shown.append, - _start_settings=lambda: opened.append(True), - ), - ) - MainWindow._open_route(window, "modrim://settings") - assert opened == [True] - assert shown == [] - - -def test_dirty_state_tracks_active_list_order_and_updates_header() -> None: - class Header: - unsaved_changes = False - - def set_unsaved_changes(self, value: bool) -> None: - self.unsaved_changes = value - - header = Header() - context = SimpleNamespace(active_uuids=["uuid-a", "uuid-b"]) - window = cast( - MainWindow, - SimpleNamespace( - _ctx=context, - _saved_active_uuids=["uuid-a", "uuid-b"], - _unsaved_changes=False, - _header_controller=header, - setWindowModified=lambda _modified: None, - ), - ) - - context.active_uuids.reverse() - MainWindow._refresh_unsaved_state(window) - assert header.unsaved_changes is True - - context.active_uuids.reverse() - MainWindow._refresh_unsaved_state(window) - assert header.unsaved_changes is False - - -def test_reload_baseline_is_list_loaded_from_disk_not_later_edits() -> None: - class Header: - unsaved_changes = False - - def set_unsaved_changes(self, value: bool) -> None: - self.unsaved_changes = value - - class Window(SimpleNamespace): - def _refresh_unsaved_state(self) -> None: - MainWindow._refresh_unsaved_state(cast(MainWindow, self)) - - header = Header() - # The user deactivated uuid-b after ctx.load() but before mods_changed fired. - context = SimpleNamespace( - active_uuids=["uuid-a"], loaded_active_uuids=["uuid-a", "uuid-b"] - ) - window = cast( - MainWindow, - Window( - _ctx=context, - _saved_active_uuids=[], - _unsaved_changes=False, - _header_controller=header, - setWindowModified=lambda _modified: None, - ), - ) - - MainWindow._on_mods_reloaded(window) - - assert header.unsaved_changes is True - - -@pytest.mark.asyncio -async def test_successful_save_clears_unsaved_state() -> None: - class ModService: - async def save_active_layout(self, _active_uuids: list[str]) -> bool: - return True - - class Header: - unsaved_changes = True - - def set_unsaved_changes(self, value: bool) -> None: - self.unsaved_changes = value - - class Window(SimpleNamespace): - def _refresh_unsaved_state(self) -> None: - MainWindow._refresh_unsaved_state(cast(MainWindow, self)) - - async def _write_active_layout(self) -> list[str] | None: - return await MainWindow._write_active_layout(cast(MainWindow, self)) - - header = Header() - context = SimpleNamespace( - active_uuids=["uuid-b", "uuid-a"], mod_service=ModService() - ) - window = cast( - MainWindow, - Window( - _ctx=context, - _saved_active_uuids=["uuid-a", "uuid-b"], - _unsaved_changes=True, - _header_controller=header, - setWindowModified=lambda _modified: None, - _toast_manager=SimpleNamespace( - success=lambda *_args: None, warning=lambda *_args: None - ), - mod_list=SimpleNamespace(active_uuids=lambda: ["uuid-b", "uuid-a"]), - ), - ) - - assert await MainWindow._save_active_mods(window) is True - assert header.unsaved_changes is False - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("choice", "save_succeeds", "expected_closed"), - [ - (QMessageBox.StandardButton.Save, True, True), - (QMessageBox.StandardButton.Save, False, False), - (QMessageBox.StandardButton.Discard, True, True), - (QMessageBox.StandardButton.Cancel, True, False), - ], -) -async def test_async_close_choice( - monkeypatch: pytest.MonkeyPatch, - choice: QMessageBox.StandardButton, - save_succeeds: bool, - expected_closed: bool, -) -> None: - async def choose( - dialog: type[QMessageBox], _parent: object - ) -> tuple[QMessageBox.StandardButton, None]: - assert dialog is UnsavedChangesDialog - return choice, None - - monkeypatch.setattr(main_window_module, "await_dialog", choose) - - class Window: - _close_prompt_open = True - _close_confirmed = False - closed = False - save_calls = 0 - - async def _save_active_mods(self) -> bool: - self.save_calls += 1 - return save_succeeds - - def close(self) -> None: - self.closed = True - - window = Window() - await MainWindow._confirm_close(cast(MainWindow, window)) - - assert window.closed is expected_closed - assert window._close_confirmed is expected_closed - assert window.save_calls == (choice == QMessageBox.StandardButton.Save) - - -class _CloseEvent: - ignored = False - accepted = False - - def ignore(self) -> None: - self.ignored = True - - def accept(self) -> None: - self.accepted = True - - -@pytest.mark.asyncio -async def test_dirty_close_event_is_ignored_and_prompts_once() -> None: - prompts = 0 - - class Window: - _unsaved_changes = True - _close_confirmed = False - _close_prompt_open = False - _close_task: asyncio.Task[None] | None = None - - async def _confirm_close(self) -> None: - nonlocal prompts - prompts += 1 - - window = Window() - first, second = _CloseEvent(), _CloseEvent() - - MainWindow.closeEvent(cast(MainWindow, window), cast(QCloseEvent, first)) - MainWindow.closeEvent(cast(MainWindow, window), cast(QCloseEvent, second)) - assert window._close_task is not None - await window._close_task - - assert first.ignored and second.ignored - assert not first.accepted - assert prompts == 1 - - -def test_confirmed_dirty_close_event_is_accepted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr( - main_window_module, "QApplication", SimpleNamespace(instance=lambda: None) - ) - quit_calls: list[None] = [] - window = SimpleNamespace( - _unsaved_changes=True, - _close_confirmed=True, - _views=[], - _update_task=None, - _launch_task=None, - _app_quit_callback=lambda: quit_calls.append(None), - deleteLater=lambda: None, - ) - event = _CloseEvent() - - MainWindow.closeEvent(cast(MainWindow, window), cast(QCloseEvent, event)) - - assert event.accepted and not event.ignored - assert quit_calls == [None] diff --git a/tests/test_ui/test_menu_bar.py b/tests/test_ui/test_menu_bar.py deleted file mode 100644 index 7deadbc..0000000 --- a/tests/test_ui/test_menu_bar.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtGui import QKeySequence -from PySide6.QtWidgets import QApplication, QMainWindow, QMenu - -from pxmodrim.ui.window.actions import ( - ACTION_SPECS, - ActionId, - create_actions, - shortcut_rows, -) -from pxmodrim.ui.window.menu_bar import MENU_LAYOUT, MenuBar - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def test_every_action_appears_in_exactly_one_menu() -> None: - placed = [a for _, ids in MENU_LAYOUT for a in ids if a is not None] - - assert sorted(placed) == sorted(ActionId) - - -def test_shortcuts_are_unique() -> None: - shortcuts = [spec.shortcut for spec in ACTION_SPECS if spec.shortcut] - - assert len(shortcuts) == len(set(shortcuts)) - - -def test_shortcut_dialog_rows_have_no_duplicates_and_collapse_view_switching() -> None: - rows = shortcut_rows() - - assert len(rows) == len(set(rows)) - assert sum(1 for _, label in rows if label == "Switch to view") == 1 - assert {label for _, label in rows} >= {"Save mod list", "Refresh mods"} - - -def test_menu_actions_carry_shortcuts_and_fire_while_menu_bar_hidden( - qapp: QApplication, -) -> None: - window = QMainWindow() - actions = create_actions(window) - menu_bar = MenuBar(actions, window) - menu_bar.hide() - triggered: list[bool] = [] - actions[ActionId.SAVE].triggered.connect(lambda: triggered.append(True)) - - file_menu = next( - menu for menu in menu_bar.findChildren(QMenu) if menu.title() == "&File" - ) - save = next(a for a in file_menu.actions() if a.text() == "&Save Mod List") - - assert save.shortcut() == QKeySequence("Ctrl+S") - assert save in window.actions() - save.trigger() - assert triggered == [True] diff --git a/tests/test_ui/test_mod_info_data.py b/tests/test_ui/test_mod_info_data.py deleted file mode 100644 index 023662d..0000000 --- a/tests/test_ui/test_mod_info_data.py +++ /dev/null @@ -1,311 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any, cast - -import pytest - -from pxmodrim.core.downloads import DownloadManager -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.models.view.diagnostics import ModIssueView -from pxmodrim.core.plugin import PluginRegistry -from pxmodrim.ui.panels.mod_info_data import ( - IMG_WIDTH_TOKEN, - _conflicts, - _needed_by, - _needs, - _pid_index, - autolink, - bbcode_images, - bbcode_markup, - build_mod_info, - description_to_html, - restore_entities, -) - - -def make_mod( - name: str, - pid: str, - deps: list[tuple[str, list[str]]] | None = None, - incompatible: list[str] | None = None, -) -> AboutXmlMod: - rules = BaseRules() - for dep_pid, alts in deps or []: - rules.dependencies[CaseInsensitiveStr(dep_pid)] = DependencyMod( - name=dep_pid, - package_id=CaseInsensitiveStr(dep_pid), - alternative_package_ids={CaseInsensitiveStr(a) for a in alts}, - ) - for i in incompatible or []: - rules.incompatible_with.add(CaseInsensitiveStr(i)) - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(pid), - about_rules=rules, - description="", - ) - - -def make_ctx(mods: list[ListedMod], active: list[str]) -> Any: - registry = PluginRegistry() - registry.register(DownloadManager()) - return cast( - Any, - SimpleNamespace( - all_mods={m.uuid: m for m in mods}, - active_uuids=active, - plugins=registry, - target_version="1.5", - ), - ) - - -class TestAutolink: - def test_bare_url_becomes_anchor(self) -> None: - assert autolink("see https://a.com/x?y=1 now") == ( - 'see https://a.com/x?y=1 now' - ) - - def test_trailing_punctuation_stays_outside(self) -> None: - assert autolink("go to https://a.com.") == ( - 'go to https://a.com.' - ) - - def test_existing_anchor_is_untouched(self) -> None: - html = 'https://b.com' - assert autolink(html) == html - - -class TestBbcodeImages: - def test_img_tag_becomes_width_constrained_image(self) -> None: - assert bbcode_images("[img]https://i.imgur.com/a.png[/img]\nhi") == ( - f'\nhi' - ) - - def test_image_url_is_not_autolinked_afterwards(self) -> None: - html = autolink(bbcode_images("[img]https://i.imgur.com/a.png[/img]")) - assert " None: - assert bbcode_markup("[url=https://a.com/w]the wiki[/url]") == ( - 'the wiki' - ) - - def test_image_inside_url_stays_inside_anchor(self) -> None: - html = bbcode_markup( - bbcode_images( - "[url=https://discord.gg/x][img]https://i.imgur.com/a.png[/img][/url]" - ) - ) - assert html.startswith('https://a.com/x" ok' - ) - - def test_angle_bracketed_url_excludes_entity(self) -> None: - assert description_to_html("") == ( - '<https://a.com/x>' - ) - - def test_ampersand_in_query_is_kept(self) -> None: - assert autolink("https://a.com/?a=1&b=2") == ( - 'https://a.com/?a=1&b=2' - ) - - -class TestDescription: - def test_rich_description_preserves_formatting_entities_and_images(self) -> None: - assert description_to_html( - "[h2]Title[/h2]\nBold •\n" - "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki" - ) == ( - "

    Title

    Bold •
    " - f'
    ' - 'https://a.com/wiki' - ) - - def test_plaintext_is_literal_and_preserves_line_breaks(self) -> None: - assert description_to_html( - "Literal & text\n[img]https://a.com/image.png[/img]\n" - "[url=https://a.com]wiki[/url]", - "plaintext", - ) == ( - "<b>Literal & text</b>
    " - "[img]https://a.com/image.png[/img]
    [url=https://a.com]wiki[/url]" - ) - - -class TestSecurity: - def test_file_img_is_not_an_image(self) -> None: - assert " None: - html = description_to_html("[img]http://a.com/i.png[/img]") - assert "' in html - - def test_javascript_url_tag_has_no_anchor(self) -> None: - assert " None: - html = description_to_html('x y') - assert " None: - html = description_to_html( - f"[url={url}]link[/url] [url]{url}[/url] [img]{url}[/img] " - f"Unity link" - ) - assert " None: - html = description_to_html('') - assert html == "<img src="file:///etc/passwd">" - - -class TestNeeds: - def test_states_and_alternatives(self) -> None: - dep_active = make_mod("Active", "a.active") - dep_inactive = make_mod("Inactive", "a.inactive") - alt = make_mod("Alt", "a.alt") - main = make_mod( - "Main", - "a.main", - deps=[ - ("a.active", []), - ("A.Inactive", []), - ("a.missing", ["a.alt"]), - ("a.gone", []), - ], - ) - ctx = make_ctx([main, dep_active, dep_inactive, alt], [dep_active.uuid]) - result = _needs(main, _pid_index(ctx), {dep_active.uuid}) - assert {r["name"]: (r["state"], r["uuid"]) for r in result} == { - "a.active": ("active", dep_active.uuid), - "A.Inactive": ("inactive", dep_inactive.uuid), - "a.missing": ("inactive", alt.uuid), - "a.gone": ("missing", ""), - } - - def test_conflicts_only_lists_active_sorted(self) -> None: - b = make_mod("beta", "x.b") - a = make_mod("Alpha", "x.a") - off = make_mod("Off", "x.off") - main = make_mod("Main", "x.main", incompatible=["X.B", "x.a", "x.off", "x.no"]) - ctx = make_ctx([main, a, b, off], [a.uuid, b.uuid]) - assert _conflicts(main, _pid_index(ctx), {a.uuid, b.uuid}) == ["Alpha", "beta"] - - def test_needed_by_matches_alternatives_and_sorts(self) -> None: - target = make_mod("Target", "t.target") - z = make_mod("zed", "t.z", deps=[("t.target", [])]) - a = make_mod("Apple", "t.a", deps=[("t.other", ["T.Target"])]) - unrelated = make_mod("Unrelated", "t.u", deps=[("t.other", [])]) - ctx = make_ctx([target, z, a, unrelated], []) - assert _needed_by(target, ctx) == [ - {"name": "Apple", "uuid": a.uuid}, - {"name": "zed", "uuid": z.uuid}, - ] - - -class TestBuildModInfo: - def test_active_mod_with_issue(self) -> None: - dep = make_mod("Dep", "d.dep") - main = make_mod("Main", "m.main", deps=[("d.dep", [])]) - main.description = "hello x https://a.com/x" - main.authors = ["Ann"] - other = make_mod("Other", "o.other") - ctx = make_ctx([dep, main, other], [other.uuid, main.uuid]) - issue = ModIssueView( - category="c", category_display_name="Cat", detail=None, is_error=False - ) - info = build_mod_info(ctx, main, [issue]) - assert info["name"] == "Main" - assert info["author"] == "Ann" - assert info["status"]["level"] == "warning" - assert info["status"]["title"] == "1 warning" - assert info["status"]["active"] is True - assert info["status"]["position"] == 2 - assert info["status"]["total"] == 2 - assert info["status"]["issues"] == [ - {"title": "Cat", "detail": "", "isError": False} - ] - assert info["needs"] == [ - {"name": "d.dep", "state": "inactive", "uuid": dep.uuid} - ] - assert '' in info["description"] - assert info["version"] == {"known": False, "ok": False, "target": "1.5"} - - def test_inactive_mod_without_description(self) -> None: - mod = make_mod("Solo", "s.solo") - info = build_mod_info(make_ctx([mod], []), mod, []) - assert info["status"]["level"] == "ok" - assert info["status"]["active"] is False - assert info["status"]["position"] == 0 - assert info["description"] == "" - assert info["neededBy"] == [] diff --git a/tests/test_ui/test_mod_list_model.py b/tests/test_ui/test_mod_list_model.py deleted file mode 100644 index 1cc916b..0000000 --- a/tests/test_ui/test_mod_list_model.py +++ /dev/null @@ -1,229 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtCore import QCoreApplication, QPersistentModelIndex, Qt -from PySide6.QtWidgets import QApplication - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.ui.models.mod_list_model import ModListModel - - -def _provider_colors() -> dict[str, str]: - return {"stub": "#abcdef"} - - -def _mod(name: str) -> AboutXmlMod: - return AboutXmlMod(name=name, provider_id="stub", valid=True) - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture -def model(qapp: QApplication) -> ModListModel: - colors = _provider_colors() - m = ModListModel(colors) - mods: dict[str, ListedMod] = {f"uuid-{i}": _mod(f"Mod {i}") for i in range(5)} - active = ["uuid-0", "uuid-2"] - m.load_mods(mods, active) - return m - - -class TestLoadMods: - def test_active_uuid_missing_from_mods_is_skipped(self, qapp: QApplication) -> None: - m = ModListModel(_provider_colors()) - m.load_mods({"a": _mod("A")}, ["ghost", "a"]) - assert m.active_uuids() == ["a"] - - -class TestSetData: - def test_toggle_check_emits_dataChanged_not_reset( - self, model: ModListModel - ) -> None: - resets: list[None] = [] - model.modelReset.connect(lambda: resets.append(None)) - data_changes: list[None] = [] - model.dataChanged.connect(lambda *_: data_changes.append(None)) - - inactive_rows = [i for i, it in enumerate(model._items) if not it.checked] - idx = model.index(inactive_rows[0], 0) - ok = model.setData(idx, Qt.CheckState.Checked, ModListModel.CheckStateRole) - assert ok is True - item = model.get_item(inactive_rows[0]) - assert item is not None - assert item.checked is True - assert data_changes - assert resets == [] - - def test_toggle_unchanged_is_noop(self, model: ModListModel) -> None: - active_rows = [i for i, it in enumerate(model._items) if it.checked] - idx = model.index(active_rows[0], 0) - data_changes: list[None] = [] - model.dataChanged.connect(lambda *_: data_changes.append(None)) - ok = model.setData(idx, Qt.CheckState.Checked, ModListModel.CheckStateRole) - assert ok is True - assert data_changes == [] - - -class TestCommitOrder: - def test_reorder_subset_preserves_positions(self, model: ModListModel) -> None: - before = [it.uuid for it in model._items] - # _items = [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - # reorder active mods uuid-0 and uuid-2 within their positions - model.commitOrder(["uuid-2", "uuid-0"]) - after = [it.uuid for it in model._items] - # uuid-2 swaps into uuid-0's position (0), uuid-0 goes to uuid-2's position (1) - assert after == ["uuid-2", "uuid-0", "uuid-1", "uuid-3", "uuid-4"] - assert set(after) == set(before) - - def test_reorder_full_list(self, model: ModListModel) -> None: - before = [it.uuid for it in model._items] - target = list(reversed(before)) - model.commitOrder(target) - assert [it.uuid for it in model._items] == target - - def test_noop_on_same_order(self, model: ModListModel) -> None: - layout_changes: list[None] = [] - model.layoutChanged.connect(lambda *_: layout_changes.append(None)) - model.commitOrder([it.uuid for it in model._items]) - assert layout_changes == [] - - def test_layout_signals_wrap_reorder_and_preserve_persistent_index( - self, model: ModListModel - ) -> None: - before = [item.uuid for item in model._items] - target = list(reversed(before)) - persistent_index = QPersistentModelIndex(model.index(0, 0)) - uuid = persistent_index.data(ModListModel.UuidRole) - events: list[tuple[str, list[str]]] = [] - - model.layoutAboutToBeChanged.connect( - lambda *_: events.append( - ("about-to-change", [item.uuid for item in model._items]) - ) - ) - model.layoutChanged.connect( - lambda *_: events.append(("changed", [item.uuid for item in model._items])) - ) - - model.commitOrder(target) - - assert events == [("about-to-change", before), ("changed", target)] - assert persistent_index.data(ModListModel.UuidRole) == uuid - - -class TestActiveUuids: - def test_returns_only_checked_in_order(self, model: ModListModel) -> None: - active = model.active_uuids() - assert set(active) == {"uuid-0", "uuid-2"} - assert active == ["uuid-0", "uuid-2"] - - -class TestLoadIndex: - def test_initial_load_indices(self, model: ModListModel) -> None: - indices = [ - model.data(model.index(r, 0), ModListModel.LoadIndexRole) - for r in range(model.rowCount()) - ] - assert indices == [1, 2, -1, -1, -1] - - def test_load_index_after_reorder(self, model: ModListModel) -> None: - changed_roles: list[list[int]] = [] - model.dataChanged.connect(lambda _t, _b, roles: changed_roles.append(roles)) - - ok = model.move_row(0, 1) - assert ok is True - - indices = [ - model.data(model.index(r, 0), ModListModel.LoadIndexRole) - for r in range(model.rowCount()) - ] - assert indices == [1, 2, -1, -1, -1] - assert model.data(model.index(0, 0), ModListModel.UuidRole) == "uuid-2" - assert model.data(model.index(1, 0), ModListModel.UuidRole) == "uuid-0" - assert any(ModListModel.LoadIndexRole in roles for roles in changed_roles) - - def test_load_index_after_toggle(self, model: ModListModel) -> None: - changed_roles: list[list[int]] = [] - model.dataChanged.connect(lambda _t, _b, roles: changed_roles.append(roles)) - - idx2 = model.index(2, 0) - assert model.data(idx2, ModListModel.LoadIndexRole) == -1 - model.setData(idx2, Qt.CheckState.Checked, ModListModel.CheckStateRole) - - assert model.data(idx2, ModListModel.LoadIndexRole) == 3 - assert any(ModListModel.LoadIndexRole in roles for roles in changed_roles) - - changed_roles.clear() - idx0 = model.index(0, 0) - model.setData(idx0, Qt.CheckState.Unchecked, ModListModel.CheckStateRole) - - assert model.data(idx0, ModListModel.LoadIndexRole) == -1 - assert model.data(model.index(1, 0), ModListModel.LoadIndexRole) == 1 - assert model.data(model.index(2, 0), ModListModel.LoadIndexRole) == 2 - assert any(ModListModel.LoadIndexRole in roles for roles in changed_roles) - - -class TestProviderLabel: - def test_provider_label_mapping(self, qapp: QApplication) -> None: - m = ModListModel({}) - mods: dict[str, ListedMod] = { - "uuid-steam": AboutXmlMod( - name="Workshop Mod", provider_id="steam", valid=True - ), - "uuid-downloaded": AboutXmlMod( - name="Downloaded Mod", provider_id="downloaded", valid=True - ), - "uuid-local": AboutXmlMod( - name="Local Mod", provider_id="local", valid=True - ), - "uuid-core": AboutXmlMod( - name="Core", - package_id=CaseInsensitiveStr("ludeon.rimworld"), - provider_id="core", - valid=True, - ), - "uuid-dlc": AboutXmlMod( - name="Royalty", - package_id=CaseInsensitiveStr("ludeon.rimworld.royalty"), - provider_id="core", - valid=True, - ), - } - m.load_mods(mods, list(mods.keys())) - - labels = { - m.data(m.index(r, 0), ModListModel.UuidRole): m.data( - m.index(r, 0), ModListModel.ProviderLabelRole - ) - for r in range(m.rowCount()) - } - assert labels["uuid-steam"] == "Workshop" - assert labels["uuid-downloaded"] == "Workshop" - assert labels["uuid-local"] == "Local" - assert labels["uuid-core"] == "Core" - assert labels["uuid-dlc"] == "DLC" - - -def test_setting_startup_impact_on_empty_model_emits_no_data_changed( - qapp: QApplication, -) -> None: - model = ModListModel(_provider_colors()) - changes: list[None] = [] - model.dataChanged.connect(lambda *_: changes.append(None)) - - model.set_startup_impact({"example.mod": 1.0}) - - assert changes == [] diff --git a/tests/test_ui/test_mod_list_panel.py b/tests/test_ui/test_mod_list_panel.py deleted file mode 100644 index d69fce3..0000000 --- a/tests/test_ui/test_mod_list_panel.py +++ /dev/null @@ -1,456 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from types import SimpleNamespace -from typing import cast - -import pytest -from PySide6.QtCore import QCoreApplication, QObject, QPoint, Qt, Signal -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication -from pytestqt.qtbot import QtBot - -from pxmodrim.core.checker.graph import ConstraintGraph, PackageId -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.services.activation_service import ActivationService -from pxmodrim.core.sort.config import SortSettings -from pxmodrim.ui.components import mod_activation -from pxmodrim.ui.components.svg_provider import create_qml_engine -from pxmodrim.ui.models.mod_list_model import ModListModel -from pxmodrim.ui.models.mod_list_proxy_model import ModListProxyModel -from pxmodrim.ui.panels.mod_list_panel import ModListPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture(scope="module") -def qml_engine(qapp: QApplication) -> Iterator[QQmlEngine]: - engine = create_qml_engine() - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - yield engine - - -class _Diagnostics(QObject): - diagnostics_summary_changed = Signal(dict) - - def summary_for(self, uuid: str) -> None: - return None - - -class _ModService(QObject): - mods_changed = Signal() - - def __init__(self, parent: QObject | None = None) -> None: - super().__init__(parent) - self.provider_colors: dict[str, str] = {} - - -class _WidgetContext(QObject): - active_state_changed = Signal(object) - - def __init__(self, mods: dict[str, ListedMod]) -> None: - super().__init__() - self.all_mods = mods - self.active_uuids: list[str] = [] - self.diagnostics_service = _Diagnostics(self) - self.mod_service = _ModService(self) - - def set_active(self, uuids: list[str]) -> None: - self.active_uuids = list(uuids) - - -def _widget_panel( - qml_engine: QQmlEngine, active_uuids: list[str] | None = None -) -> ModListPanel: - mods = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b"), - "uuid-c": _mod("Mod C", "mod.c"), - } - ctx = _WidgetContext(cast(dict[str, ListedMod], mods)) - panel = ModListPanel(cast(CoreContext, ctx), qml_engine) - panel.load_mods(cast(dict[str, ListedMod], mods), active_uuids or []) - return panel - - -def _qml_list_property(list_view: QObject, name: str) -> object: - value = list_view.property(name) - return value.toVariant() if hasattr(value, "toVariant") else value - - -def _mod(name: str, package_id: str, dependency: str = "") -> AboutXmlMod: - dependencies = {} - if dependency: - dependencies[CaseInsensitiveStr(dependency)] = DependencyMod( - name=dependency, - package_id=CaseInsensitiveStr(dependency), - ) - return AboutXmlMod( - name=name, - package_id=CaseInsensitiveStr(package_id), - provider_id="stub", - about_rules=BaseRules(dependencies=dependencies), - ) - - -def _panel(qapp: QApplication) -> ModListPanel: - mods = { - "uuid-a": _mod("Mod A", "mod.a"), - "uuid-b": _mod("Mod B", "mod.b", "mod.a"), - "uuid-c": _mod("Mod C", "mod.c", "mod.b"), - } - graph = ConstraintGraph() - graph.build( - {PackageId(str(mod.package_id)): mod for mod in mods.values()}, - [PackageId("mod.a"), PackageId("mod.b"), PackageId("mod.c")], - SortSettings(use_community_rules=False, use_alternative_package_ids=False), - ) - ctx = SimpleNamespace( - all_mods=mods, - diagnostics_service=SimpleNamespace( - constraint_graph=graph, community_rules=None - ), - active_uuids=["uuid-a", "uuid-b", "uuid-c"], - config=AppConfig(), - ) - - panel = ModListPanel.__new__(ModListPanel) - panel._highlighted = frozenset() - panel._highlight_revision = 0 - panel._highlight_generation = 0 - panel._qml = cast(QQuickWidget, SimpleNamespace(rootObject=lambda: None)) - panel._ctx = cast(CoreContext, ctx) - panel._model = ModListModel({}) - panel._proxy = ModListProxyModel(panel._model) - panel._model.load_mods(cast(dict[str, ListedMod], mods), ctx.active_uuids) - - def set_active(uuids: list[str]) -> None: - ctx.active_uuids = list(uuids) - panel._on_core_state_changed(tuple(uuids)) - - ctx.set_active = set_active - ctx.activation = ActivationService(cast(CoreContext, ctx)) - return panel - - -@pytest.mark.asyncio -async def test_disabling_dependency_can_leave_dependents_active( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - calls: list[list[str]] = [] - - async def choose(*args: object) -> tuple[object, object]: - calls.append(list(args[1])) # type: ignore[arg-type] - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.No, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([0]) - - assert panel.active_uuids() == ["uuid-b", "uuid-c"] - assert calls == [["Mod B (mod.b)", "Mod C (mod.c)"]] - - -@pytest.mark.asyncio -async def test_batch_disable_all_removes_transitive_dependents( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def choose(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.Yes, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([0, 1]) - - assert panel.active_uuids() == [] - - -@pytest.mark.asyncio -async def test_cancel_keeps_selected_mod_and_dependents_active( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def choose(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.Cancel, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([0]) - - assert panel.active_uuids() == ["uuid-a", "uuid-b", "uuid-c"] - - -@pytest.mark.asyncio -async def test_reenabled_mod_is_placed_between_dependency_and_dependent( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def choose(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.No, None - - monkeypatch.setattr(mod_activation, "await_dialog", choose) - await panel._toggle_rows([1]) - assert panel.active_uuids() == ["uuid-a", "uuid-c"] - - await panel._toggle_rows([panel.rowForUuid("uuid-b")]) - - assert panel.active_uuids() == ["uuid-a", "uuid-b", "uuid-c"] - assert panel._ctx.active_uuids == ["uuid-a", "uuid-b", "uuid-c"] - - -@pytest.mark.asyncio -async def test_disabled_mod_leaves_active_block( - qapp: QApplication, monkeypatch: pytest.MonkeyPatch -) -> None: - panel = _panel(qapp) - - async def keep_dependents(*args: object) -> tuple[object, object]: - from PySide6.QtWidgets import QMessageBox - - return QMessageBox.StandardButton.No, None - - monkeypatch.setattr(mod_activation, "await_dialog", keep_dependents) - await panel._toggle_rows([panel.rowForUuid("uuid-b")]) - - rows = [(item.uuid, item.checked, item.load_index) for item in panel._model._items] - assert rows == [ - ("uuid-a", True, 1), - ("uuid-c", True, 2), - ("uuid-b", False, -1), - ] - - -def test_hovering_drag_handle_does_not_show_drag_proxy( - qml_engine: QQmlEngine, - qtbot: QtBot, -) -> None: - panel = _widget_panel(qml_engine) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - drag_proxy = root.findChild(QObject, "dragProxy") - assert drag_proxy is not None - - QTest.mouseMove(panel._qml, QPoint(4, 26)) - QTest.mouseMove(panel._qml, QPoint(14, 26)) - - assert drag_proxy.property("visible") is False - - -def test_small_jitter_on_drag_handle_does_not_start_drag( - qml_engine: QQmlEngine, - qtbot: QtBot, -) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b", "uuid-c"]) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - drag_proxy = root.findChild(QObject, "dragProxy") - assert drag_proxy is not None - - QTest.mousePress( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(20, 26), - ) - QTest.mouseMove(panel._qml, QPoint(21, 26)) - visible = drag_proxy.property("visible") - QTest.mouseRelease( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(21, 26), - ) - - assert visible is False - - -@pytest.mark.asyncio -async def test_highlighting_enabled_mod_keeps_user_selection( - qml_engine: QQmlEngine, -) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b", "uuid-c"]) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - root.selectRow(0, "uuid-a", 0) # type: ignore[attr-defined] - - panel._highlight_rows(["uuid-c"]) - panel.set_search_filter("Mod") - panel.set_search_filter("") - - assert panel.isHighlighted("uuid-c") is True - assert panel.isHighlighted("uuid-a") is False - assert _qml_list_property(list_view, "selectedUuids") == ["uuid-a"] - assert list_view.property("currentUuid") == "uuid-a" - - -def test_highlight_tints_selected_row(qml_engine: QQmlEngine) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b"]) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - - def colors() -> set[str]: - items = list_view.property("contentItem").childItems() - return {c.property("color").name() for c in items if c.property("color")} - - root.selectRow(0, "uuid-a", 0) # type: ignore[attr-defined] - QTest.qWait(0) - before = colors() - - panel._set_highlighted(["uuid-a"]) - QTest.qWait(0) - after = colors() - - assert before != after - - -def test_qml_selection_clears_when_filter_hides_current_row( - qml_engine: QQmlEngine, -) -> None: - panel = _widget_panel(qml_engine) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - assert list_view.property("currentIndex") == -1 - assert list_view.property("anchorIndex") == -1 - assert _qml_list_property(list_view, "selectedIndices") == [] - empty_state = root.findChild(QObject, "emptyStateText") - assert empty_state is not None - assert empty_state.property("visible") is False - root.selectRow(2, "uuid-c", 0) # type: ignore[attr-defined] - panel.set_search_filter("Mod A") - assert list_view.property("count") == 1 - assert list_view.property("currentIndex") == -1 - assert list_view.property("anchorIndex") == -1 - assert _qml_list_property(list_view, "selectedIndices") == [] - - panel.set_search_filter("not present") - assert list_view.property("count") == 0 - assert list_view.property("currentIndex") == -1 - assert list_view.property("anchorIndex") == -1 - assert _qml_list_property(list_view, "selectedIndices") == [] - assert empty_state.property("visible") is True - - -def test_qml_selection_follows_uuid_when_selected_row_moves( - qml_engine: QQmlEngine, -) -> None: - panel = _widget_panel(qml_engine, ["uuid-a", "uuid-b", "uuid-c"]) - root = panel._qml.rootObject() - assert root is not None - list_view = root.findChild(QObject, "listView") - assert list_view is not None - - root.selectRow(2, "uuid-c", 0) # type: ignore[attr-defined] - panel.moveRow(2, 0) - - assert panel.uuidAt(0) == "uuid-c" - assert list_view.property("currentIndex") == 0 - assert _qml_list_property(list_view, "selectedIndices") == [0] - assert _qml_list_property(list_view, "selectedUuids") == ["uuid-c"] - - -def test_search_focus_disables_list_keyboard_actions( - qml_engine: QQmlEngine, - qtbot: QtBot, -) -> None: - panel = _widget_panel(qml_engine) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - - QTest.mouseClick( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(100, 26), - ) - qtbot.waitUntil(lambda: root.property("keyboardActive") is True) - assert root.property("keyboardActive") is True - panel.search_input.setFocus() - qtbot.waitUntil(lambda: root.property("keyboardActive") is False) - QTest.keyClick(panel.search_input, Qt.Key.Key_Return) - assert panel.active_uuids() == [] - - -def test_space_in_search_inserts_text_without_toggling_mods( - qml_engine: QQmlEngine, qtbot: QtBot -) -> None: - panel = _widget_panel(qml_engine) - panel.resize(420, 320) - panel.show() - qtbot.waitUntil(lambda: panel._qml.isVisible()) - root = panel._qml.rootObject() - assert root is not None - - QTest.mouseClick( - panel._qml, - Qt.MouseButton.LeftButton, - Qt.KeyboardModifier.NoModifier, - QPoint(100, 26), - ) - qtbot.waitUntil(lambda: root.property("keyboardActive") is True) - root.selectRow(0, "uuid-a", 0) # type: ignore[attr-defined] - active_before = panel.active_uuids() - panel.search_input.setFocus() - qtbot.waitUntil(lambda: root.property("keyboardActive") is False) - - assert root.property("keyboardActive") is False - QTest.keyClick(panel.search_input, Qt.Key.Key_Space) - - assert panel.search_input.text() == " " - assert panel.active_uuids() == active_before - - -@pytest.mark.asyncio -async def test_mod_selection_presenter_clears_on_empty_uuid() -> None: - from unittest.mock import MagicMock - - from pxmodrim.ui.mod_selection import ModSelectionPresenter - - panel_mock = MagicMock() - ctx = _WidgetContext({}) - presenter = ModSelectionPresenter(cast(CoreContext, ctx), panel_mock) - await presenter.show("") - panel_mock.clear.assert_called_once() diff --git a/tests/test_ui/test_mod_list_proxy_model.py b/tests/test_ui/test_mod_list_proxy_model.py deleted file mode 100644 index 5c3d08b..0000000 --- a/tests/test_ui/test_mod_list_proxy_model.py +++ /dev/null @@ -1,306 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtCore import QCoreApplication, QPersistentModelIndex -from PySide6.QtWidgets import QApplication - -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.ui.models.mod_list_model import ModListModel -from pxmodrim.ui.models.mod_list_proxy_model import ModListProxyModel - - -def _provider_colors() -> dict[str, str]: - return {"stub": "#abcdef"} - - -def _mod(name: str, pid: str = "") -> AboutXmlMod: - package_id = CaseInsensitiveStr(pid or name.lower()) - return AboutXmlMod(name=name, package_id=package_id, provider_id="stub", valid=True) - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture -def source(qapp: QApplication) -> ModListModel: - colors = _provider_colors() - m = ModListModel(colors) - mods: dict[str, ListedMod] = { - f"uuid-{i}": _mod(f"Mod {i}", f"mod.{i}") for i in range(5) - } - active = ["uuid-0", "uuid-2"] - m.load_mods(mods, active) - return m - - -@pytest.fixture -def proxy(source: ModListModel) -> ModListProxyModel: - return ModListProxyModel(source) - - -class TestSidebarFilter: - def test_no_filter_shows_all(self, proxy: ModListProxyModel) -> None: - assert proxy.rowCount() == 5 - - def test_filter_to_subset(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-2", "uuid-4"}) - assert proxy.rowCount() == 3 - - rows = [ - proxy.mapToSource(proxy.index(i, 0)).row() for i in range(proxy.rowCount()) - ] - # source _items = [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - assert rows == [0, 1, 4] - - def test_filter_to_empty(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter(set()) - assert proxy.rowCount() == 0 - - def test_filter_reset_to_none(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0"}) - assert proxy.rowCount() == 1 - proxy.set_sidebar_filter(None) - assert proxy.rowCount() == 5 - - -class TestSearchFilter: - def test_search_by_name(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("Mod 1") - assert proxy.rowCount() == 1 - src_row = proxy.mapToSource(proxy.index(0, 0)).row() - # source _items = [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - assert src_row == 2 - - def test_search_by_package_id(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("mod.3") - assert proxy.rowCount() == 1 - src_row = proxy.mapToSource(proxy.index(0, 0)).row() - assert src_row == 3 - - def test_search_empty_shows_all(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("") - assert proxy.rowCount() == 5 - - def test_search_no_match(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("zzzzzz") - assert proxy.rowCount() == 0 - - def test_search_case_insensitive(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("MOD 2") - assert proxy.rowCount() == 1 - - def test_search_whitespace_only_shows_all(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter(" \t \n ") - assert proxy.rowCount() == 5 - - def test_search_normalized_unicode_and_whitespace(self, qapp: QApplication) -> None: - colors = _provider_colors() - source = ModListModel(colors) - mods: dict[str, ListedMod] = { - "uuid-u": _mod("Große Mod", "grosse.mod"), - "uuid-p": _mod(" Padded Mod ", " padded.pkg "), - } - source.load_mods(mods, ["uuid-u", "uuid-p"]) - proxy = ModListProxyModel(source) - - proxy.set_search_filter(" GROßE ") - assert proxy.rowCount() == 1 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 0 - - proxy.set_search_filter(" padded.pkg ") - assert proxy.rowCount() == 1 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 1 - - -class TestCombinedFilter: - def test_sidebar_and_search(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-1", "uuid-2", "uuid-3"}) - proxy.set_search_filter("Mod 2") - assert proxy.rowCount() == 1 - src_row = proxy.mapToSource(proxy.index(0, 0)).row() - # uuid-2 is at source row 1 in [uuid-0, uuid-2, uuid-1, uuid-3, uuid-4] - assert src_row == 1 - - def test_sidebar_excludes_search_match(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-1"}) - proxy.set_search_filter("Mod 3") - assert proxy.rowCount() == 0 - - def test_clearing_search_returns_to_sidebar(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-1", "uuid-2"}) - proxy.set_search_filter("Mod 2") - assert proxy.rowCount() == 1 - proxy.set_search_filter("") - assert proxy.rowCount() == 2 - - -class TestMapToSource: - def test_round_trip(self, proxy: ModListProxyModel) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-2", "uuid-4"}) - for proxy_row in range(proxy.rowCount()): - proxy_index = proxy.index(proxy_row, 0) - src_index = proxy.mapToSource(proxy_index) - assert src_index.isValid() - src_row = src_index.row() - mapped_back = proxy.mapFromSource(src_index) - assert mapped_back == proxy_index - - def test_mapping_after_move_row(self, proxy: ModListProxyModel) -> None: - assert proxy.move_row(0, 2) - for r in range(proxy.rowCount()): - src_idx = proxy.mapToSource(proxy.index(r, 0)) - assert src_idx.isValid() - assert proxy.mapFromSource(src_idx).row() == r - - def test_mapping_after_filter_rebuild( - self, proxy: ModListProxyModel, source: ModListModel - ) -> None: - proxy.set_sidebar_filter({"uuid-1", "uuid-3"}) - assert proxy.rowCount() == 2 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 2 - assert proxy.mapToSource(proxy.index(1, 0)).row() == 3 - assert proxy.mapFromSource(source.index(2, 0)).row() == 0 - assert proxy.mapFromSource(source.index(3, 0)).row() == 1 - assert not proxy.mapFromSource(source.index(0, 0)).isValid() - - proxy.set_sidebar_filter(None) - proxy.set_search_filter("Mod 4") - assert proxy.rowCount() == 1 - assert proxy.mapToSource(proxy.index(0, 0)).row() == 4 - assert proxy.mapFromSource(source.index(4, 0)).row() == 0 - assert not proxy.mapFromSource(source.index(2, 0)).isValid() - - proxy.set_search_filter("") - assert proxy.rowCount() == 5 - for r in range(proxy.rowCount()): - src_idx = proxy.mapToSource(proxy.index(r, 0)) - assert proxy.mapFromSource(src_idx).row() == r - - def test_mapping_after_move_with_active_filter( - self, proxy: ModListProxyModel, source: ModListModel - ) -> None: - proxy.set_sidebar_filter({"uuid-0", "uuid-1", "uuid-4"}) - assert proxy.rowCount() == 3 - assert proxy.move_row(0, 1) - for r in range(proxy.rowCount()): - src_idx = proxy.mapToSource(proxy.index(r, 0)) - assert src_idx.isValid() - assert proxy.mapFromSource(src_idx).row() == r - assert not proxy.mapFromSource(source.index(0, 0)).isValid() - - def test_persistent_indexes_follow_uuid_across_filter_rebuild( - self, proxy: ModListProxyModel - ) -> None: - kept = QPersistentModelIndex(proxy.index(2, 0)) - filtered = QPersistentModelIndex(proxy.index(1, 0)) - assert kept.data(ModListModel.UuidRole) == "uuid-1" - - proxy.set_sidebar_filter({"uuid-1", "uuid-3"}) - - assert kept.isValid() - assert kept.row() == 0 - assert kept.data(ModListModel.UuidRole) == "uuid-1" - assert not filtered.isValid() - - -class TestIsFiltered: - def test_transitions_on_search_filter(self, proxy: ModListProxyModel) -> None: - transitions: list[bool] = [] - proxy.is_filtered_changed.connect( - lambda: transitions.append(bool(proxy.property("isFiltered"))) - ) - - assert proxy.property("isFiltered") is False - assert proxy.is_filtered is False - - proxy.set_search_filter("mod") - assert proxy.property("isFiltered") is True - assert proxy.is_filtered is True - assert transitions == [True] - - proxy.set_search_filter("") - assert proxy.property("isFiltered") is False - assert proxy.is_filtered is False - assert transitions == [True, False] - - def test_transitions_on_sidebar_filter(self, proxy: ModListProxyModel) -> None: - transitions: list[bool] = [] - proxy.is_filtered_changed.connect( - lambda: transitions.append(bool(proxy.property("isFiltered"))) - ) - - assert proxy.property("isFiltered") is False - - proxy.set_sidebar_filter({"uuid-0"}) - assert proxy.property("isFiltered") is True - assert transitions == [True] - - proxy.set_sidebar_filter(None) - assert proxy.property("isFiltered") is False - assert transitions == [True, False] - - -class TestClampedMove: - def test_clamped_move_clamps_target_to_active_block( - self, proxy: ModListProxyModel - ) -> None: - assert proxy.activeCount == 2 - ok = proxy.move_row(0, 4, clamp_to_active=True) - assert ok is True - assert proxy.data(proxy.index(0, 0), ModListModel.UuidRole) == "uuid-2" - assert proxy.data(proxy.index(1, 0), ModListModel.UuidRole) == "uuid-0" - assert proxy.data(proxy.index(2, 0), ModListModel.UuidRole) == "uuid-1" - assert proxy.mapToSource(proxy.index(0, 0)).row() == 0 - assert proxy.mapToSource(proxy.index(1, 0)).row() == 1 - assert proxy.mapToSource(proxy.index(2, 0)).row() == 2 - - def test_clamped_move_rejects_inactive_source( - self, proxy: ModListProxyModel - ) -> None: - ok = proxy.move_row(2, 0, clamp_to_active=True) - assert ok is False - - def test_clamped_move_rejects_when_filtered(self, proxy: ModListProxyModel) -> None: - proxy.set_search_filter("mod") - assert proxy.isFiltered is True - ok = proxy.move_row(0, 1, clamp_to_active=True) - assert ok is False - - -def test_move_refreshes_load_indices_only_after_move_completes( - qapp: QApplication, -) -> None: - source = ModListModel(_provider_colors()) - mods: dict[str, ListedMod] = {f"u{i}": _mod(f"Mod {i}") for i in range(3)} - source.load_mods(mods, ["u0", "u1", "u2"]) - proxy = ModListProxyModel(source) - events: list[tuple[str, int, int]] = [] - proxy.rowsAboutToBeMoved.connect(lambda *_: events.append(("begin", -1, -1))) - proxy.rowsMoved.connect(lambda *_: events.append(("end", -1, -1))) - proxy.dataChanged.connect( - lambda top, bottom, _roles: events.append(("data", top.row(), bottom.row())) - ) - - assert proxy.move_row(0, 2) is True - - assert events == [("begin", -1, -1), ("end", -1, -1), ("data", 0, 2)] - rows = [ - ( - proxy.data(proxy.index(row, 0), ModListModel.UuidRole), - proxy.data(proxy.index(row, 0), ModListModel.LoadIndexRole), - ) - for row in range(3) - ] - assert rows == [("u1", 1), ("u2", 2), ("u0", 3)] diff --git a/tests/test_ui/test_mod_tree_model.py b/tests/test_ui/test_mod_tree_model.py deleted file mode 100644 index 5299f53..0000000 --- a/tests/test_ui/test_mod_tree_model.py +++ /dev/null @@ -1,508 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator, Callable, Iterator -from pathlib import Path -from types import SimpleNamespace -from typing import cast - -import pytest -from PySide6.QtCore import QCoreApplication, QMetaObject, QObject, QPoint, QPointF, Qt -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuick import QQuickItem -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QWidget -from pytestqt.qtbot import QtBot - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - CaseInsensitiveStr, - ListedMod, -) -from pxmodrim.core.models.view.diagnostics import ModDiagnosticsView -from pxmodrim.core.organizer import ROOT_ID, OrganizerDb, OrganizerService, TreeFilter -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.components.icon_button import IconButton -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.plugins.organizer.filter_model import OrganizerFilterModel -from pxmodrim.ui.plugins.organizer.tree_model import ModTreeModel -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel -from pxmodrim.ui.theme.qml_theme import Theme -from pxmodrim.ui.views.mods_view import ModsViewPanel - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture -async def organizer( - tmp_path: Path, -) -> AsyncIterator[tuple[OrganizerService, SimpleNamespace]]: - mods: dict[str, ListedMod] = { - "u1": AboutXmlMod( - name="One", - _uuid="u1", - package_id=CaseInsensitiveStr("a.one"), - provider_id="local", - ), - "u2": AboutXmlMod( - name="Two", - _uuid="u2", - package_id=CaseInsensitiveStr("a.two"), - provider_id="steam", - ), - "u3": AboutXmlMod( - name="Loose", - _uuid="u3", - package_id=CaseInsensitiveStr("loose.mod"), - provider_id="local", - ), - } - ctx = SimpleNamespace( - all_mods=mods, - active_uuids=["u1"], - diagnostics_service=SimpleNamespace(summary_for=lambda _uuid: None), - ) - svc = OrganizerService( - cast(CoreContext, ctx), OrganizerDb(tmp_path / "organizer.db") - ) - await svc.init(cast(CoreContext, ctx)) - yield svc, ctx - await svc.shutdown() - - -async def test_nested_folder_rows_and_ungrouped( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - top = await svc.create_folder("Top") - sub = await svc.create_folder("Sub", top.id) - await svc.place(["a.one"], top.id) - await svc.place(["a.two"], sub.id) - model = ModTreeModel(lambda _uuid: None) - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - - assert model.rowCount() == 2 - root_folder = model.index(0, 0) - ungrouped = model.index(1, 0) - assert model.data(root_folder) == "Top" - assert model.data(ungrouped) == "Ungrouped" - assert model.data(model.index(0, 0, root_folder)) == "Sub" - assert model.data(model.index(1, 0, root_folder)) == "One" - assert model.data(model.index(0, 0, ungrouped)) == "Loose" - assert model.parent(model.index(0, 0, ungrouped)) == ungrouped - assert model.parent(model.index(0, 0, root_folder)) == root_folder - assert model.parent(root_folder).isValid() is False - assert model.data(ungrouped, model.TotalCountRole) == 1 - assert model.data(root_folder, model.TotalCountRole) == 2 - - -async def test_check_states_and_filtered_counts( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - folder = await svc.create_folder_from("Group", ["a.one", "a.two"]) - model = ModTreeModel(lambda _uuid: None) - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - idx = model.index(0, 0) - assert model.data(idx, model.CheckStateRole) == Qt.CheckState.PartiallyChecked - assert ( - model.data(model.index(0, 0, idx), model.CheckStateRole) - == Qt.CheckState.Checked - ) - assert ( - model.data(model.index(1, 0, idx), model.CheckStateRole) - == Qt.CheckState.Unchecked - ) - assert ( - model.data(model.index(1, 0), model.CheckStateRole) == Qt.CheckState.Unchecked - ) - - from pxmodrim.core.organizer import TreeQuery - - model.set_tree(svc.tree(TreeQuery(text="One")), ctx.all_mods, {}, True) - idx = model.index(0, 0) - assert model.data(idx, model.VisibleCountRole) == 1 - assert model.data(idx, model.TotalCountRole) == 2 - assert model.data(idx, model.CheckStateRole) == Qt.CheckState.PartiallyChecked - assert model.index_for_key(f"f:{folder.id}").isValid() - - -async def test_folder_toggle_applies_once_for_descendants( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - top = await svc.create_folder("Top") - sub = await svc.create_folder("Sub", top.id) - await svc.place(["a.one"], top.id) - await svc.place(["a.two"], sub.id) - calls: list[tuple[list[str], list[str]]] = [] - - class Activation: - def dependents_of(self, uuids: list[str], *, enable: list[str]) -> list[str]: - return [] - - def apply(self, enable: list[str], disable: list[str]) -> bool: - calls.append((enable, disable)) - return True - - ctx.activation = Activation() - view = OrganizerViewPanel.__new__(OrganizerViewPanel) - view._ctx = cast(CoreContext, ctx) - view._service = svc - view.model = ModTreeModel(lambda _uuid: None) - view.model.set_tree(svc.tree(), ctx.all_mods, {}, False) - node = view.model.node_at(view.model.index_for_key(f"f:{top.id}")) - assert node is not None - await view._toggle_folder(node) - assert calls == [(["u1", "u2"], [])] - assert svc.folder_mod_uuids(ROOT_ID, recursive=False) == ["u3"] - - -async def test_issue_markers_only_show_on_enabled_mods( - organizer: tuple[OrganizerService, SimpleNamespace], qapp: QApplication -) -> None: - svc, ctx = organizer - summary = ModDiagnosticsView( - has_errors=True, - has_warnings=True, - error_tooltip="Error", - warning_tooltip="Incompatible", - ) - model = ModTreeModel(lambda _uuid: summary) - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - ungrouped = model.index(0, 0) - enabled = model.index_for_key("m:u1") - inactive = model.index_for_key("m:u2") - assert model.parent(enabled) == ungrouped - assert model.data(enabled, model.HasErrorRole) is True - assert model.data(enabled, model.HasWarningRole) is True - assert model.data(inactive, model.HasErrorRole) is False - assert model.data(inactive, model.HasWarningRole) is False - - ctx.active_uuids.append("u2") - model.set_tree(svc.tree(), ctx.all_mods, {}, False) - assert model.data(model.index_for_key("m:u2"), model.HasWarningRole) is True - - -@pytest.mark.asyncio -async def test_qml_tree_click_passes_model_index_to_panel( - tmp_path: Path, qapp: QApplication, qtbot: QtBot -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - mod = AboutXmlMod( - name="Example", - _uuid="example", - package_id=CaseInsensitiveStr("example.mod"), - provider_id="local", - ) - ctx.load({"example": mod}, []) - service = OrganizerService(ctx, OrganizerDb(tmp_path / "organizer.db")) - ctx.register_plugin(service) - await service.init(ctx) - engine: QQmlEngine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(900, 600) - view.resize(900, 600) - parent.show() - view.show() - view._rebuild() - try: - tree = view._qml.rootObject().findChild(QObject, "organizerTreeView") - assert tree is not None - assert view.model.rowCount() == 1 - qtbot.waitUntil(lambda: tree.property("contentHeight") > 0) - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=QPoint(200, 20)) - assert [node.kind for node in view.model.selected_nodes()] == ["ungrouped"] - finally: - view.close() - parent.deleteLater() - await service.shutdown() - - -def _click_quick(widget: QWidget, item: QQuickItem) -> None: - center = item.mapToScene(QPointF(item.width() / 2, item.height() / 2)) - QTest.mouseClick( - widget, - Qt.MouseButton.LeftButton, - pos=QPoint(round(center.x()), round(center.y())), - ) - - -def _find_quick_item(root_item: QQuickItem, name: str) -> QQuickItem | None: - if root_item.objectName() == name: - return root_item - for child in root_item.childItems(): - found = _find_quick_item(child, name) - if found is not None: - return found - return None - - -async def _until(predicate: Callable[[], object], timeout: float = 10.0) -> None: - deadline = asyncio.get_running_loop().time() + timeout - while not predicate() and asyncio.get_running_loop().time() < deadline: - await asyncio.sleep(0.02) - QTest.qWait(5) - - -@pytest.mark.asyncio -async def test_qml_tag_editor_persists_assignments_and_edits( - tmp_path: Path, qapp: QApplication, qtbot: QtBot -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - mods: dict[str, ListedMod] = { - uuid: AboutXmlMod( - name=name, - _uuid=uuid, - package_id=CaseInsensitiveStr(package_id), - provider_id="local", - ) - for uuid, name, package_id in ( - ("example", "Example", "example.mod"), - ("second", "Second", "second.mod"), - ) - } - ctx.load(mods, []) - db = OrganizerDb(tmp_path / "organizer.db") - service = OrganizerService(ctx, db) - ctx.register_plugin(service) - await service.init(ctx) - engine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(1400, 850) - view.setGeometry(parent.rect()) - parent.show() - view.show() - view._rebuild() - try: - root = view._qml.rootObject() - assert root is not None - tags = root.findChild(QObject, "organizerTagEditor") - assert tags is not None - selected = view.model.index_for_key("m:example") - assert selected.isValid() - view.model.select((selected,)) - view._sync_info() - await view.selectionAction("tags") - qtbot.waitUntil(lambda: tags.property("visible")) - name = tags.findChild(QQuickItem, "organizerNewTagName") - color = tags.findChild(QQuickItem, "organizerNewTagColor") - add_tag = tags.findChild(QQuickItem, "organizerAddTag") - error = tags.findChild(QQuickItem, "organizerTagError") - assert name is not None and color is not None and add_tag is not None - assert error is not None - name.setProperty("text", "Favorites") - color.setProperty("text", "not-a-color") - _click_quick(view._qml, add_tag) - await _until(lambda: "valid color" in str(error.property("text"))) - assert "valid color" in error.property("text") - assert name.property("text") == "Favorites" - assert color.property("text") == "not-a-color" - assert not service.state.tags - - color.setProperty("text", "#3399aa") - _click_quick(view._qml, add_tag) - await _until(lambda: bool(service.state.tags)) - assert [(tag.name, tag.color) for tag in service.state.tags.values()] == [ - ("Favorites", "#3399aa") - ] - assert error.property("text") == "" - - first = view.model.index_for_key("m:example") - second = view.model.index_for_key("m:second") - assert first.isValid() and second.isValid() - view.model.select((first,)) - view._sync_info() - await asyncio.sleep(0.03) - QTest.qWait(10) - repeater = tags.findChild(QQuickItem, "organizerTagRepeater") - assert repeater is not None - repeater_parent = repeater.parentItem() - assert repeater_parent is not None - assignment = _find_quick_item(repeater_parent, "organizerTagAssignment") - assert assignment is not None and assignment.isVisible() - assert assignment.property("checkState") == Qt.CheckState.Unchecked - _click_quick(view._qml, assignment) - await _until(lambda: "example.mod" in service.state.mod_tags) - tag_id = next(iter(service.state.tags)) - assert service.state.mod_tags["example.mod"] == frozenset({tag_id}) - assert assignment.property("checkState") == Qt.CheckState.Checked - - view.model.select((first, second)) - view._sync_info() - await asyncio.sleep(0.03) - QTest.qWait(10) - assert assignment.property("checkState") == Qt.CheckState.PartiallyChecked - _click_quick(view._qml, assignment) - await _until(lambda: "second.mod" in service.state.mod_tags) - assert service.state.mod_tags["second.mod"] == frozenset({tag_id}) - assert assignment.property("checkState") == Qt.CheckState.Checked - _click_quick(view._qml, assignment) - await _until(lambda: not service.state.mod_tags.get("example.mod")) - assert not service.state.mod_tags.get("example.mod") - assert not service.state.mod_tags.get("second.mod") - assert assignment.property("checkState") == Qt.CheckState.Unchecked - - edit = _find_quick_item(repeater_parent, "organizerEditTag") - tag_name = _find_quick_item(repeater_parent, "organizerTagName") - tag_color = _find_quick_item(repeater_parent, "organizerTagColor") - assert edit is not None and tag_name is not None and tag_color is not None - _click_quick(view._qml, edit) - assert not tag_name.property("readOnly") - tag_name.setProperty("text", "Best") - tag_color.setProperty("text", "bad") - _click_quick(view._qml, edit) - await _until(lambda: "valid color" in str(error.property("text"))) - assert "valid color" in error.property("text") - assert tag_name.property("text") == "Best" - assert tag_color.property("text") == "bad" - assert not tag_name.property("readOnly") - assert service.state.tags[tag_id].name == "Favorites" - - tag_color.setProperty("text", "#123456") - _click_quick(view._qml, edit) - await _until(lambda: service.state.tags[tag_id].name == "Best") - assert (service.state.tags[tag_id].name, service.state.tags[tag_id].color) == ( - "Best", - "#123456", - ) - assert tag_name.property("readOnly") - assert error.property("text") == "" - persisted = await db.load() - assert (persisted.tags[tag_id].name, persisted.tags[tag_id].color) == ( - "Best", - "#123456", - ) - assert not persisted.mod_tags.get("example.mod") - assert not persisted.mod_tags.get("second.mod") - - delete_btn = _find_quick_item(repeater_parent, "organizerDeleteTag") - assert delete_btn is not None - _click_quick(view._qml, delete_btn) - delete_confirm = tags.findChild(QObject, "organizerDeleteTagConfirm") - assert delete_confirm is not None - qtbot.waitUntil(lambda: delete_confirm.property("visible")) - QMetaObject.invokeMethod(delete_confirm, "accept") - await _until(lambda: not service.state.tags) - assert not service.state.tags - persisted = await db.load() - assert not persisted.tags - finally: - view.close() - parent.deleteLater() - await service.shutdown() - - -def test_organizer_filter_model_sections_icons_and_fallbacks( - qapp: QApplication, -) -> None: - model = OrganizerFilterModel() - assert model.rowCount() == 0 - assert model.for_key("missing").key == "all" - - entries = [ - TreeFilter("all", "All", 10), - TreeFilter("active", "Active", 6, status="active"), - TreeFilter("inactive", "Inactive", 4, status="inactive"), - TreeFilter("provider:steam", "Steam", 8, provider_id="steam"), - TreeFilter("tag:1", "Medieval", 5, tag_id=1), - TreeFilter("tag:2", "Empty", 0, tag_id=2), - ] - model.update(entries) - assert model.rowCount() == 6 - - # SectionRole checks - assert [ - model.data(model.index(i), OrganizerFilterModel.SectionRole) for i in range(6) - ] == ["Status", "Status", "Status", "Providers", "Tags", "Tags"] - - # IconRole checks - assert [ - model.data(model.index(i), OrganizerFilterModel.IconRole) for i in range(6) - ] == ["grid", "check-circle", "ban", "steam", "tag", "tag"] - - # Key and Display role checks - assert model.data(model.index(4), OrganizerFilterModel.KeyRole) == "tag:1" - assert model.data(model.index(4), Qt.ItemDataRole.DisplayRole) == "Medieval" - assert model.data(model.index(4), OrganizerFilterModel.CountRole) == 5 - assert model.data(model.index(5), OrganizerFilterModel.CountRole) == 0 - - # for_key lookup and fallback for deleted/unknown tag - assert model.for_key("tag:1").label == "Medieval" - assert model.for_key("tag:999").key == "all" - - # In-place update (label rename without key change) - renamed_entries = [ - TreeFilter("all", "All", 10), - TreeFilter("active", "Active", 6, status="active"), - TreeFilter("inactive", "Inactive", 4, status="inactive"), - TreeFilter("provider:steam", "Steam", 8, provider_id="steam"), - TreeFilter("tag:1", "Medieval Overhaul", 5, tag_id=1), - TreeFilter("tag:2", "Empty", 0, tag_id=2), - ] - model.update(renamed_entries) - label = model.data(model.index(4), Qt.ItemDataRole.DisplayRole) - assert label == "Medieval Overhaul" - - -@pytest.mark.asyncio -async def test_organizer_sidebar_geometry_matches_mods_after_dismissing_hint( - tmp_path: Path, qapp: QApplication -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - ctx.load({}, []) - service = OrganizerService(ctx, OrganizerDb(tmp_path / "organizer.db")) - ctx.register_plugin(service) - await service.init(ctx) - engine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - parent.resize(1300, 780) - organizer = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - mods = ModsViewPanel(ctx, engine, parent, AppContext(ctx)) - organizer.setGeometry(parent.rect()) - mods.setGeometry(parent.rect()) - parent.show() - organizer.show() - mods.show() - try: - QTest.qWait(10) - for _ in range(100): - if organizer.mod_info.width() == mods.mod_info.width(): - break - QTest.qWait(10) - assert organizer._sidebar.width() == mods.sidebar.width() - assert ( - organizer._sidebar.rootObject().childItems()[0].width() - == mods.sidebar.rootObject().childItems()[0].width() - ) - assert organizer.mod_info.width() == mods.mod_info.width() - - mods.hide() - hint = organizer.findChild(QWidget, "organizerHint") - assert hint is not None - dismiss = hint.findChild(IconButton) - assert dismiss is not None - width = organizer.mod_info.width() - dismiss.click() - QTest.qWait(10) - assert not hint.isVisible() - assert organizer.mod_info.width() == width - finally: - organizer.close() - mods.close() - parent.deleteLater() - await service.shutdown() diff --git a/tests/test_ui/test_mod_updates.py b/tests/test_ui/test_mod_updates.py deleted file mode 100644 index 0908ba3..0000000 --- a/tests/test_ui/test_mod_updates.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget - -from pxmodrim.ui.components import mod_updates - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() or QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.mark.asyncio -async def test_small_update_skips_confirmation( - qapp: QApplication, - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fail(*_args: object) -> tuple[int, object]: - raise AssertionError("dialog must not open") - - monkeypatch.setattr(mod_updates, "await_dialog", fail) - parent = QWidget() - assert await mod_updates.confirm_update(mod_updates.CONFIRM_THRESHOLD - 1, parent) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("answer", "expected"), - [ - (QMessageBox.StandardButton.Yes, True), - (QMessageBox.StandardButton.Cancel, False), - ], -) -async def test_large_update_follows_dialog_answer( - qapp: QApplication, - monkeypatch: pytest.MonkeyPatch, - answer: QMessageBox.StandardButton, - expected: bool, -) -> None: - async def fake(*_args: object) -> tuple[int, object]: - return answer, object() - - monkeypatch.setattr(mod_updates, "await_dialog", fake) - parent = QWidget() - result = await mod_updates.confirm_update(mod_updates.CONFIRM_THRESHOLD, parent) - assert result is expected diff --git a/tests/test_ui/test_navigation.py b/tests/test_ui/test_navigation.py deleted file mode 100644 index be675b4..0000000 --- a/tests/test_ui/test_navigation.py +++ /dev/null @@ -1,21 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.ui.navigation import Route, build_route, parse_route - - -def test_route_round_trips_ids_with_reserved_characters() -> None: - url = build_route("workshop", "collection", "picked:a/b") - assert url == "modrim://workshop/collection/picked%3Aa%2Fb" - assert parse_route(url) == Route("workshop", ("collection", "picked:a/b")) - - -def test_view_only_route_has_empty_path() -> None: - assert parse_route("modrim://downloads") == Route("downloads") - assert parse_route("modrim://downloads/") == Route("downloads") - - -@pytest.mark.parametrize("url", ["downloads", "https://downloads", "modrim://", ""]) -def test_non_routes_are_rejected(url: str) -> None: - assert parse_route(url) is None diff --git a/tests/test_ui/test_organizer_dialogs.py b/tests/test_ui/test_organizer_dialogs.py deleted file mode 100644 index 225a4e9..0000000 --- a/tests/test_ui/test_organizer_dialogs.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtWidgets import QApplication, QMessageBox, QWidget - -from pxmodrim.ui.plugins.organizer.dialogs import DeleteFolderDialog - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() or QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def test_delete_folder_dialog_confirms_with_yes_button(qapp: QApplication) -> None: - parent = QWidget() - dialog = DeleteFolderDialog("Fixes", parent) - yes = dialog.button(QMessageBox.StandardButton.Yes) - assert yes is not None - assert yes.text() == "Delete" - yes.click() - assert dialog.result() == QMessageBox.StandardButton.Yes diff --git a/tests/test_ui/test_organizer_rule_editor.py b/tests/test_ui/test_organizer_rule_editor.py deleted file mode 100644 index 9540b68..0000000 --- a/tests/test_ui/test_organizer_rule_editor.py +++ /dev/null @@ -1,327 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import Callable, Iterator -from itertools import pairwise -from pathlib import Path - -import pytest -from PySide6.QtCore import ( - Q_ARG, - QCoreApplication, - QMetaObject, - QObject, - QPoint, - QPointF, - Qt, -) -from PySide6.QtQml import QQmlEngine -from PySide6.QtQuick import QQuickItem -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QWidget - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.models.metadata.structures import AboutXmlMod, CaseInsensitiveStr -from pxmodrim.core.organizer import STANDARD_RULES, OrganizerDb, OrganizerService -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.plugins.organizer.view import OrganizerViewPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -async def _until(condition: Callable[[], bool], timeout: float = 10.0) -> None: - """Pump both Qt and asyncio (asyncSlot tasks) until ``condition`` holds.""" - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while not condition(): - assert loop.time() < deadline, "condition not met before timeout" - QTest.qWait(10) - await asyncio.sleep(0) - - -def _click(widget: QWidget, item: QQuickItem) -> None: - center = item.mapToScene(QPointF(item.width() / 2, item.height() / 2)) - QTest.mouseClick( - widget, - Qt.MouseButton.LeftButton, - pos=QPoint(round(center.x()), round(center.y())), - ) - - -def _set_pattern(editor: QObject, index: int, value: str) -> None: - assert QMetaObject.invokeMethod( - editor, - "setRulePattern", - Qt.ConnectionType.DirectConnection, - Q_ARG("QVariant", index), - Q_ARG("QVariant", value), - ) - - -def _find_quick_item(root_item: QQuickItem, name: str) -> QQuickItem | None: - if root_item.objectName() == name: - return root_item - for child in root_item.childItems(): - found = _find_quick_item(child, name) - if found is not None: - return found - return None - - -def _control(row: QQuickItem, name: str) -> QQuickItem: - control = _find_quick_item(row, name) - assert control is not None, f"Control {name} not found in row" - return control - - -def _rows(dialog: QObject) -> list[QQuickItem]: - repeater = dialog.findChild(QQuickItem, "organizerRuleRepeater") - assert repeater is not None - column = repeater.parentItem() - assert column is not None - rows = [i for i in column.childItems() if i.objectName() == "organizerRuleRow"] - return sorted(rows, key=lambda item: item.y()) - - -async def _laid_out_rows(dialog: QObject, count: int) -> list[QQuickItem]: - """Wait until ``count`` rows exist and the layout has positioned them.""" - - def settled() -> bool: - rows = _rows(dialog) - ys = [row.y() for row in rows] - return len(rows) == count and all(a < b for a, b in pairwise(ys)) - - await _until(settled) - return _rows(dialog) - - -def _patterns(dialog: QObject) -> list[str]: - return [ - str(_control(row, "organizerRulePattern").property("text")) - for row in _rows(dialog) - ] - - -def _hits(dialog: QObject, index: int) -> str: - rows = _rows(dialog) - if len(rows) <= index: - return "" - return str(_control(rows[index], "organizerRuleHits").property("text")) - - -@pytest.mark.asyncio -async def test_rule_editor_persists_ordered_rules_and_keeps_invalid_draft( - tmp_path: Path, qapp: QApplication -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - ctx.load( - { - "example": AboutXmlMod( - name="Example", - _uuid="example", - package_id=CaseInsensitiveStr("example.mod"), - provider_id="local", - ) - }, - [], - ) - db = OrganizerDb(tmp_path / "organizer.db") - service = OrganizerService(ctx, db) - ctx.register_plugin(service) - await service.init(ctx) - first = await service.create_folder("First") - second = await service.create_folder("Second") - engine: QQmlEngine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(920, 700) - view.setGeometry(parent.rect()) - view.show() - parent.show() - # Earlier tests may leave another top-level active; key input needs ours. - parent.activateWindow() - assert QTest.qWaitForWindowActive(parent) - view._rebuild() - try: - qml = view._rules_qml - dialog = qml.rootObject() - assert dialog is not None - rules_button = view.rules_button - - async def open_editor(count: int) -> list[QQuickItem]: - QTest.mouseClick(rules_button, Qt.MouseButton.LeftButton) - await _until(lambda: bool(dialog.property("opened"))) - # Opening the QML root does not wait for native window activation. - assert QTest.qWaitForWindowActive(view._rules_dialog) - return await _laid_out_rows(dialog, count) - - async def save_and_close() -> None: - assert save is not None - _click(qml, save) - await _until(lambda: not dialog.property("visible")) - - await open_editor(0) - assert view._rules_dialog.isVisible() - assert dialog.property("width") == qml.width() - assert dialog.property("height") == qml.height() - initial_size = view._rules_dialog.size() - view._rules_dialog.resize(initial_size.width() + 24, initial_size.height() + 24) - await _until( - lambda: ( - dialog.property("width") == qml.width() - and dialog.property("height") == qml.height() - ) - ) - assert qml.width() > initial_size.width() - add = dialog.findChild(QQuickItem, "organizerAddRule") - save = dialog.findChild(QQuickItem, "organizerSaveRules") - assert add is not None and save is not None - assert dialog.property("width") <= qml.width() - - # Add a rule, type its pattern, save. - _click(qml, add) - await _laid_out_rows(dialog, 1) - _set_pattern(dialog, 0, "example") - assignable = dialog.findChild(QQuickItem, "organizerRuleAssignable") - assert assignable is not None - await _until(lambda: "1 mod matches first here" in _hits(dialog, 0)) - assert "1 currently ungrouped mod would" in str(assignable.property("text")) - await save_and_close() - - persisted = await db.load() - assert [(r.field, r.op, r.pattern, r.folder_id) for r in persisted.rules] == [ - ("package_id", "prefix", "example", first.id) - ] - assert service.folder_mod_uuids(first.id) == ["example"] - - # Add a second rule targeting the second folder, then move it up. - await open_editor(1) - _click(qml, add) - _, new_row = await _laid_out_rows(dialog, 2) - _set_pattern(dialog, 1, "example.mod") - new_row = (await _laid_out_rows(dialog, 2))[1] - operator_box = _control(new_row, "organizerRuleOperator") - _click(qml, operator_box) - await _until(lambda: bool(operator_box.property("down"))) - QTest.keyClick(qml, Qt.Key.Key_Down) - QTest.keyClick(qml, Qt.Key.Key_Down) - QTest.keyClick(qml, Qt.Key.Key_Return) - await _until(lambda: operator_box.property("currentValue") == "equals") - new_row = (await _laid_out_rows(dialog, 2))[1] - folder_box = _control(new_row, "organizerRuleFolder") - _click(qml, folder_box) - await _until(lambda: bool(folder_box.property("down"))) - QTest.keyClick(qml, Qt.Key.Key_Down) - QTest.keyClick(qml, Qt.Key.Key_Return) - await _until(lambda: folder_box.property("currentValue") == second.id) - - _click(qml, _control(new_row, "organizerRuleUp")) - await _until(lambda: _patterns(dialog) == ["example.mod", "example"]) - await _until(lambda: "1 mod matches first here" in _hits(dialog, 0)) - await _laid_out_rows(dialog, 2) - await save_and_close() - - persisted = await db.load() - assert [(r.op, r.pattern, r.folder_id) for r in persisted.rules] == [ - ("equals", "example.mod", second.id), - ("prefix", "example", first.id), - ] - assert service.folder_mod_uuids(second.id) == ["example"] - - # An invalid pattern keeps the dialog and the unsaved draft visible. - top, _ = await open_editor(2) - _set_pattern(dialog, 0, " ") - _click(qml, save) - await _until( - lambda: "pattern cannot be empty" in str(dialog.property("errorMessage")) - ) - assert dialog.property("visible") - assert _patterns(dialog) == [" ", "example"] - assert len((await db.load()).rules) == 2 - - # Remove the invalid rule from the kept draft and save the remainder. - _click(qml, _control(top, "organizerRuleRemove")) - await _laid_out_rows(dialog, 1) - assert _patterns(dialog) == ["example"] - await save_and_close() - - persisted = await db.load() - assert [(r.pattern, r.folder_id) for r in persisted.rules] == [ - ("example", first.id) - ] - assert service.folder_mod_uuids(first.id) == ["example"] - finally: - view.close() - parent.deleteLater() - await service.shutdown() - - -@pytest.mark.asyncio -async def test_rule_editor_adds_standard_rules_only_from_clean_draft( - tmp_path: Path, qapp: QApplication -) -> None: - ctx = CoreContext.create(AppConfig(), ConfigService(tmp_path)) - ctx.load({}, []) - db = OrganizerDb(tmp_path / "organizer.db") - service = OrganizerService(ctx, db) - ctx.register_plugin(service) - await service.init(ctx) - await service.create_folder("Mine") - engine: QQmlEngine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - parent = QWidget() - view = OrganizerViewPanel(ctx, engine, parent, AppContext(ctx)) - parent.resize(920, 700) - view.setGeometry(parent.rect()) - view.show() - parent.show() - parent.activateWindow() - assert QTest.qWaitForWindowActive(parent) - view._rebuild() - try: - qml = view._rules_qml - dialog = qml.rootObject() - assert dialog is not None - QTest.mouseClick(view.rules_button, Qt.MouseButton.LeftButton) - await _until(lambda: bool(dialog.property("opened"))) - assert QTest.qWaitForWindowActive(view._rules_dialog) - await _laid_out_rows(dialog, 0) - standard = dialog.findChild(QQuickItem, "organizerAddStandardRules") - assert standard is not None - assert standard.property("enabled") - - _click(qml, standard) - rows = await _laid_out_rows(dialog, len(STANDARD_RULES)) - assert len(rows) == len(STANDARD_RULES) - scroll_area = dialog.findChild(QQuickItem, "organizerRuleScrollArea") - assert scroll_area is not None - assert scroll_area.property("contentHeight") > scroll_area.height() - assert _patterns(dialog) == [rule.pattern for rule in STANDARD_RULES] - assert dialog.property("visible") - assert dialog.property("errorMessage") == "" - - persisted = await db.load() - folders = {f.id: f.name for f in persisted.folders.values()} - assert [ - (folders[r.folder_id], r.field, r.op, r.pattern) for r in persisted.rules - ] == [(s.folder, s.field, s.op, s.pattern) for s in STANDARD_RULES] - - # A dirty draft must be saved or discarded before adding more rules. - assert standard.property("enabled") - _set_pattern(dialog, 0, "edited") - await _until(lambda: not standard.property("enabled")) - finally: - view.close() - parent.deleteLater() - await service.shutdown() diff --git a/tests/test_ui/test_procedural_preview.py b/tests/test_ui/test_procedural_preview.py deleted file mode 100644 index f6d0dda..0000000 --- a/tests/test_ui/test_procedural_preview.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -import pytest -from PySide6.QtGui import QGuiApplication, QImage - -from pxmodrim.ui.components.procedural_preview import generate_preview, initials - - -@pytest.mark.parametrize( - ("title", "expected"), - [ - ("[AV] Framework - Russian Translation", "FR"), - ("SF Grim Reality", "SG"), - ("Vanilla Expanded Framework", "VE"), - ("RimworldTweaks", "RT"), - ("Combat", "CO"), - ("[AV]", "AV"), - ("[AV] Framework", "AF"), - ("The Lord of Rings", "LR"), - ("Русский перевод", "РП"), - ("---", "--"), - ], -) -def test_initials(title: str, expected: str) -> None: - assert initials(title) == expected - - -def test_generate_preview_is_thread_safe_image() -> None: - app = QGuiApplication.instance() or QGuiApplication([]) - assert app is not None - image = generate_preview("[AV] Framework", 120, 60) - assert isinstance(image, QImage) - assert (image.width(), image.height()) == (120, 60) - assert not image.isNull() - assert generate_preview("[AV] Framework", 120, 60) is image diff --git a/tests/test_ui/test_restore_snapshot_dialog.py b/tests/test_ui/test_restore_snapshot_dialog.py deleted file mode 100644 index 7aa8dd0..0000000 --- a/tests/test_ui/test_restore_snapshot_dialog.py +++ /dev/null @@ -1,324 +0,0 @@ -from __future__ import annotations - -import asyncio -import threading -import time -from collections.abc import Iterator -from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock - -import pytest -from PySide6.QtWidgets import ( - QAbstractButton, - QApplication, - QDialog, - QListWidget, - QMessageBox, - QWidget, -) - -from pxmodrim.core import mod_service as mod_service_module -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import CaseInsensitiveStr, ModsConfig -from pxmodrim.core.mods_config import parse_mods_config, write_mods_config -from pxmodrim.ui.panels.restore_snapshot_dialog import ( - ConfirmRestoreDialog, - RestoreSnapshotDialog, -) - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def _write_snapshot(path: Path, *package_ids: str) -> None: - write_mods_config( - path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr(value) for value in package_ids], - knownExpansions=[], - ), - path.parent, - ) - - -def test_restore_dialog_offscreen_selection_and_accept_reject( - qapp: QApplication, tmp_path: Path -) -> None: - valid_snap = tmp_path / "ModsConfig_20260922_120000.xml" - _write_snapshot(valid_snap, "mod.foo", "mod.bar") - corrupt_snap = tmp_path / "ModsConfig_20260922_130000.xml" - corrupt_snap.write_text("<<>>", encoding="utf-8") - - dialog = RestoreSnapshotDialog([valid_snap, corrupt_snap]) - list_widget = dialog.findChild(QListWidget) - assert list_widget is not None - assert list_widget.count() == 2 - - # Initial selection is valid_snap - assert dialog.selected_snapshot == valid_snap - restore_btn = next( - b for b in dialog.findChildren(QAbstractButton) if b.text() == "Restore" - ) - cancel_btn = next( - b for b in dialog.findChildren(QAbstractButton) if b.text() == "Cancel" - ) - assert restore_btn.isEnabled() is True - - # Change selection to corrupt item - list_widget.setCurrentRow(1) - assert dialog.selected_snapshot is None - assert restore_btn.isEnabled() is False - - # Switch back to valid item - list_widget.setCurrentRow(0) - assert dialog.selected_snapshot == valid_snap - assert restore_btn.isEnabled() is True - - # Cancel button rejects dialog - cancel_btn.click() - assert dialog.result() == QDialog.DialogCode.Rejected - - # Re-instantiate and test double click accept - dialog2 = RestoreSnapshotDialog([valid_snap]) - list_widget2 = dialog2.findChild(QListWidget) - assert list_widget2 is not None - dialog2._on_item_double_clicked(list_widget2.item(0)) - assert dialog2.result() == QDialog.DialogCode.Accepted - - -def test_confirm_restore_dialog_paths(qapp: QApplication) -> None: - confirm = ConfirmRestoreDialog("ModsConfig_20260922_120000.xml") - assert confirm.windowTitle() == "Restore Mod List" - assert "ModsConfig_20260922_120000.xml" in confirm.text() - assert confirm.defaultButton() == confirm.button(QMessageBox.StandardButton.Cancel) - - restore_btn = confirm.button(QMessageBox.StandardButton.Yes) - assert restore_btn is not None - assert restore_btn.text() == "Restore" - cancel_btn = confirm.button(QMessageBox.StandardButton.Cancel) - assert cancel_btn is not None - assert cancel_btn.text() == "Cancel" - - -@pytest.mark.asyncio -async def test_mod_service_restore_snapshot( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - - mock_reload = AsyncMock() - monkeypatch.setattr(ModService, "reload", mock_reload) - - current_cfg_path = game_config_dir / "ModsConfig.xml" - write_mods_config( - current_cfg_path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("current.mod")], - knownExpansions=[], - ), - config_dir / "snapshots", - ) - - snapshots_dir = config_dir / "snapshots" - snapshots_dir.mkdir(parents=True, exist_ok=True) - snapshot_path = snapshots_dir / "ModsConfig_20260922_100000.xml" - _write_snapshot(snapshot_path, "restored.mod") - - assert await service.restore_snapshot(snapshot_path) is True - mock_reload.assert_awaited_once() - - restored = parse_mods_config(current_cfg_path) - assert restored is not None - assert restored.activeMods == [CaseInsensitiveStr("restored.mod")] - - snapshots = service.get_snapshots() - assert len(snapshots) == 2 - current_backup = next(path for path in snapshots if path != snapshot_path) - backup = parse_mods_config(current_backup) - assert backup is not None - assert backup.activeMods == [CaseInsensitiveStr("current.mod")] - - -@pytest.mark.asyncio -async def test_mod_service_restore_invalid_snapshot( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - mock_reload = AsyncMock() - monkeypatch.setattr(ModService, "reload", mock_reload) - current_cfg_path = game_config_dir / "ModsConfig.xml" - write_mods_config( - current_cfg_path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("safe.mod")], - knownExpansions=[], - ), - config_dir / "snapshots", - ) - original = current_cfg_path.read_bytes() - - corrupt = config_dir / "snapshots" / "ModsConfig_corrupt.xml" - corrupt.parent.mkdir(parents=True, exist_ok=True) - corrupt.write_text("NOT XML", encoding="utf-8") - - assert await service.restore_snapshot(corrupt) is False - mock_reload.assert_not_called() - assert current_cfg_path.read_bytes() == original - - -@pytest.mark.asyncio -async def test_mod_service_initialize_snapshots_existing_config( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - - mock_refresh = AsyncMock() - monkeypatch.setattr(ModService, "_run_refresh_pipeline", mock_refresh) - - current_cfg_path = game_config_dir / "ModsConfig.xml" - initial_mods = ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr("current.mod")], - knownExpansions=[], - ) - write_mods_config(current_cfg_path, initial_mods, config_dir / "snapshots") - original_bytes = current_cfg_path.read_bytes() - - for snap in (config_dir / "snapshots").glob("*.xml"): - snap.unlink() - assert service.get_snapshots() == [] - - await service.initialize() - mock_refresh.assert_awaited_once_with("initialize") - - snapshots = service.get_snapshots() - assert len(snapshots) == 1 - snap_data = parse_mods_config(snapshots[0]) - assert snap_data is not None - assert snap_data.activeMods == [CaseInsensitiveStr("current.mod")] - assert current_cfg_path.read_bytes() == original_bytes - - saved = await service.save_active_layout([]) - assert saved is True - assert len(service.get_snapshots()) == 1 - - -@pytest.mark.asyncio -async def test_mod_service_configurable_retention_honored( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - - app_cfg = AppConfig(max_snapshots=3) - app_cfg.paths.config_folder = str(game_config_dir) - - cfg_svc = ConfigService(config_dir) - ctx = CoreContext(app_cfg, cfg_svc) - service = ModService(ctx, []) - - current_cfg_path = game_config_dir / "ModsConfig.xml" - for i in range(5): - write_mods_config( - current_cfg_path, - ModsConfig( - version="1.5", - activeMods=[CaseInsensitiveStr(f"mod.{i}")], - knownExpansions=[], - ), - config_dir / "snapshots", - max_snapshots=ctx.config.max_snapshots, - ) - - assert len(service.get_snapshots()) == 3 - - -def test_settings_panel_save_preserves_max_snapshots(qapp: QApplication) -> None: - from pxmodrim.ui.components.svg_provider import create_qml_engine - from pxmodrim.ui.panels.settings_panel import SettingsPanel - from pxmodrim.ui.theme.qml_theme import Theme - - cfg = AppConfig(max_snapshots=7) - ctx = CoreContext(cfg) - host = QWidget() - engine = create_qml_engine() - engine.rootContext().setContextProperty("Theme", Theme(engine)) - panel = SettingsPanel(ctx, engine, host) - panel._save(panel._backend._initial) - assert panel.get_config().max_snapshots == 7 - - -@pytest.mark.asyncio -async def test_concurrent_layout_saves_do_not_overlap( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - config_dir = tmp_path / "px_config" - config_dir.mkdir() - game_config_dir = tmp_path / "game_config" - game_config_dir.mkdir() - app_cfg = AppConfig() - app_cfg.paths.config_folder = str(game_config_dir) - service = ModService(CoreContext(app_cfg, ConfigService(config_dir)), []) - - running = 0 - peak = 0 - lock = threading.Lock() - real_write = mod_service_module.write_mods_config - - def slow_write(*args: Any, **kwargs: Any) -> None: - nonlocal running, peak - with lock: - running += 1 - peak = max(peak, running) - time.sleep(0.05) - real_write(*args, **kwargs) - with lock: - running -= 1 - - monkeypatch.setattr(mod_service_module, "write_mods_config", slow_write) - await asyncio.gather(*(service.save_active_layout([]) for _ in range(4))) - assert peak == 1 diff --git a/tests/test_ui/test_settings_panel.py b/tests/test_ui/test_settings_panel.py deleted file mode 100644 index 00fa86e..0000000 --- a/tests/test_ui/test_settings_panel.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -import asyncio -from typing import Any - -from PySide6.QtCore import Qt -from PySide6.QtQuick import QQuickItem -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QWidget - -from pxmodrim.core.config import AppConfig -from pxmodrim.core.context import CoreContext -from pxmodrim.ui.components.svg_provider import create_qml_engine -from pxmodrim.ui.panels.settings_panel import SettingsPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -def _button(root: QQuickItem, text: str) -> QQuickItem | None: - for child in root.findChildren(QQuickItem): - if child.metaObject().className().startswith("PxButton") and ( - child.property("text") == text - ): - return child - return None - - -async def test_cancel_click_closes_settings_and_unloads_without_errors( - qapp: QApplication, -) -> None: - host = QWidget() - engine = create_qml_engine(host) - engine.rootContext().setContextProperty("Theme", Theme(engine)) - warnings: list[Any] = [] - engine.warnings.connect(warnings.extend) - panel = SettingsPanel(CoreContext(AppConfig()), engine, host) - panel.resize(760, 640) - panel.show() - await asyncio.sleep(0.3) - root = panel._qml.rootObject() - assert isinstance(root, QQuickItem) - cancel = _button(root, "Cancel") - assert cancel is not None - center = cancel.mapToScene(cancel.boundingRect().center()).toPoint() - - QTest.mouseClick(panel._qml, Qt.MouseButton.LeftButton, pos=center) - QTest.qWait(20) - assert panel._qml.rootObject() is None - assert not panel.isVisible() - panel.deleteLater() - await asyncio.sleep(0.05) - assert [w.toString() for w in warnings] == [] - host.deleteLater() diff --git a/tests/test_ui/test_sidebar_model.py b/tests/test_ui/test_sidebar_model.py deleted file mode 100644 index fc2c18b..0000000 --- a/tests/test_ui/test_sidebar_model.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from PySide6.QtCore import QCoreApplication -from PySide6.QtWidgets import QApplication - -from pxmodrim.ui.models.sidebar_model import SidebarModel -from pxmodrim.ui.theme.palette import PALETTE - - -class ProviderEntry: - def __init__(self, label: str) -> None: - self.label = label - self.count = 2 - - -def test_update_entries_refreshes_provider_icon() -> None: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - model = SidebarModel() - model.set_entries([ProviderEntry("Steam")]) - changed_roles: list[list[int]] = [] - model.dataChanged.connect(lambda _, __, roles: changed_roles.append(roles)) - - model.update_entries([ProviderEntry("Local")]) - - assert model.data(model.index(0, 0), SidebarModel.IconRole) == "folder" - assert ( - model.data(model.index(0, 0), SidebarModel.IconColorRole) == PALETTE["TEXT_DIM"] - ) - assert SidebarModel.IconRole in changed_roles[0] diff --git a/tests/test_ui/test_startup_impact_data.py b/tests/test_ui/test_startup_impact_data.py deleted file mode 100644 index 7b2822b..0000000 --- a/tests/test_ui/test_startup_impact_data.py +++ /dev/null @@ -1,132 +0,0 @@ -from __future__ import annotations - -import pytest - -from pxmodrim.core.services.startup_impact_service.models import ( - BASE_GAME_PID, - StartupImpactMod, - StartupImpactReport, -) -from pxmodrim.ui.models.impact import format_duration -from pxmodrim.ui.panels.startup_impact_data import ( - BASE_GAME_LABEL, - build_startup_impact, -) - -_P = "LoadingProgress.StartupImpact." - - -def _report() -> StartupImpactReport: - return StartupImpactReport( - path="", - loading_time_s=0.0, - timestamp="", - mods=( - StartupImpactMod("Small", "a.small", 1.0, {f"{_P}ApplyPatches": 1.0}), - StartupImpactMod( - "Big", - "b.big", - 4.0, - { - f"{_P}ModContentPackReloadContentInt.Textures": 3.0, - f"{_P}ApplyPatches": 1.0, - }, - off_thread_total_impact_s=0.5, - ), - StartupImpactMod( - BASE_GAME_PID, - BASE_GAME_PID, - 10.0, - {f"{_P}GlobalTextureAtlasManagerBakeStaticAtlases": 10.0}, - ), - ), - ) - - -def _build(**kwargs: object) -> dict: - args: dict = {"active_pids": [], "selected_pid": None, "estimated_s": 0.0} - args.update(kwargs) - return build_startup_impact(_report(), **args) - - -def test_mods_exclude_base_game_and_rank_by_time() -> None: - data = _build() - assert [m["name"] for m in data["mods"]] == ["Big", "Small"] - assert [m["rank"] for m in data["mods"]] == [1, 2] - - -def test_segments_scale_to_slowest_mod() -> None: - big, small = _build()["mods"] - assert sum(s["fraction"] for s in big["segments"]) == pytest.approx(1.0) - assert sum(s["fraction"] for s in small["segments"]) == pytest.approx(0.25) - assert big["dominant"] == "Textures & audio" - assert big["off_thread"] == "500 ms" - - -def test_active_and_selected_match_normalized_package_ids() -> None: - data = _build(active_pids=["B.Big_steam"], selected_pid="A.SMALL") - flags = {m["name"]: (m["active"], m["selected"]) for m in data["mods"]} - assert flags == {"Big": (True, False), "Small": (False, True)} - assert data["active_count"] == 1 - assert data["selected_key"] == "a.small" - - -def test_estimate_hidden_without_active_list() -> None: - assert _build(estimated_s=12.0)["estimated"] == "" - assert _build(active_pids=["a.small"], estimated_s=12.0)["estimated"] == "12.00 s" - - -def test_last_launch_falls_back_to_measured_sum() -> None: - assert _build()["last_launch"] == "15.00 s" - - -def test_summary_splits_base_mods_off_thread() -> None: - summary = _build()["summary"] - assert [s["parallel"] for s in summary] == [False, False, True] - assert sum(s["fraction"] for s in summary) == pytest.approx(1.0) - assert summary[0]["fraction"] == pytest.approx(10.0 / 15.5) - - -def test_phases_toggle_base_game_contribution() -> None: - data = _build(selected_pid="b.big") - with_base = data["phases_with_base"] - assert [p["label"] for p in with_base] == ["Textures & audio", "Patches"] - content = with_base[0] - assert content["value"] == "13.00 s" - assert content["share"] == "87%" - assert [(m["name"], m["base"]) for m in content["mods"]] == [ - (BASE_GAME_LABEL, True), - ("Big", False), - ] - assert content["mods"][1]["selected"] - - mods_only = data["phases_mods_only"] - assert [(p["label"], p["value"]) for p in mods_only] == [ - ("Textures & audio", "3.00 s"), - ("Patches", "2.00 s"), - ] - assert all(not m["base"] for p in mods_only for m in p["mods"]) - - -@pytest.mark.parametrize( - ("seconds", "text"), - [ - (0.0004, "0 ms"), - (0.25, "250 ms"), - (59.5, "59.50 s"), - (60.0, "1m 00s"), - (268.7, "4m 29s"), - ], -) -def test_format_duration(seconds: float, text: str) -> None: - assert format_duration(seconds) == text - - -def test_tooltip_sums_metrics_sharing_a_label() -> None: - from pxmodrim.core.services.startup_impact_service.phases import Phase - from pxmodrim.ui.panels.startup_impact_data import _metrics_by_phase - - grouped = _metrics_by_phase( - {"ApplyPatches|Foo -> a": 1.0, "ApplyPatches|Foo -> b": 2.0} - ) - assert list(grouped[Phase.PATCHES].values()) == [3.0] diff --git a/tests/test_ui/test_toast_and_progress.py b/tests/test_ui/test_toast_and_progress.py deleted file mode 100644 index 1e5ea3b..0000000 --- a/tests/test_ui/test_toast_and_progress.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator - -import pytest -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QApplication, QPushButton, QWidget - -from pxmodrim.core.loading import LoadingState -from pxmodrim.ui.components.progress_dialog import ProgressDialog -from pxmodrim.ui.components.toast import ToastManager - - -@pytest.fixture -def qapp() -> Iterator[QApplication]: - app = QApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -def test_close_button_removes_persistent_toast(qapp: QApplication) -> None: - parent = QWidget() - manager = ToastManager(parent) - manager.show_toast("stays", "info", duration_ms=0) - toast = manager._active_toasts[0] - - close_button = toast.findChild(QPushButton, "toastClose") - assert close_button is not None - close_button.click() - QTest.qWait(300) - - assert manager._active_toasts == [] - assert manager._layout.count() == 0 - - -def test_progress_dialog_stays_visible_when_new_task_starts_before_hide( - qapp: QApplication, -) -> None: - parent = QWidget() - loading = LoadingState(parent) - dialog = ProgressDialog(loading, parent) - - with loading.task("first"): - pass - with loading.task("second"): - QTest.qWait(500) - assert dialog.isVisible() - assert dialog.windowTitle() == "second" - - QTest.qWait(800) - assert not dialog.isVisible() diff --git a/tests/test_ui/test_unity_rich_text.py b/tests/test_ui/test_unity_rich_text.py deleted file mode 100644 index bd97e97..0000000 --- a/tests/test_ui/test_unity_rich_text.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import annotations - -from pxmodrim.ui.components.unity_rich_text import unity_rich_text_to_html - - -class TestUnityRichTextConverter: - def test_basic_text(self) -> None: - assert unity_rich_text_to_html("Hello world") == "Hello world" - - def test_bold(self) -> None: - assert unity_rich_text_to_html("bold") == "bold" - assert unity_rich_text_to_html("bold") == "bold" - - def test_italic(self) -> None: - assert unity_rich_text_to_html("italic") == "italic" - assert unity_rich_text_to_html("italic") == "italic" - - def test_underline(self) -> None: - assert ( - unity_rich_text_to_html("underline") - == 'underline' - ) - - def test_strikethrough(self) -> None: - assert unity_rich_text_to_html("strike") == "strike" - - def test_color_named(self) -> None: - assert ( - unity_rich_text_to_html("red") - == 'red' - ) - - def test_color_hex(self) -> None: - assert ( - unity_rich_text_to_html("red") - == 'red' - ) - - def test_size_px(self) -> None: - assert ( - unity_rich_text_to_html("big") - == 'big' - ) - - def test_size_percent(self) -> None: - assert ( - unity_rich_text_to_html("big") - == 'big' - ) - - def test_size_em(self) -> None: - assert ( - unity_rich_text_to_html("big") - == 'big' - ) - - def test_size_relative(self) -> None: - assert ( - unity_rich_text_to_html("relative") - == 'relative' - ) - - def test_size_negative(self) -> None: - assert ( - unity_rich_text_to_html("small") - == 'small' - ) - - def test_align(self) -> None: - assert ( - unity_rich_text_to_html('center') - == '
    center
    ' - ) - - def test_indent(self) -> None: - assert ( - unity_rich_text_to_html("indented") - == '
    indented
    ' - ) - - def test_mark(self) -> None: - assert ( - unity_rich_text_to_html("marked") - == 'marked' - ) - - def test_sub_sup(self) -> None: - assert unity_rich_text_to_html("sub") == "sub" - assert unity_rich_text_to_html("sup") == "sup" - - def test_link(self) -> None: - assert ( - unity_rich_text_to_html('link') - == '
    link' - ) - - def test_link_rejects_unsafe_urls(self) -> None: - for url in ( - "javascript:alert(1)", - "steam://rungameid/294100", - "https:example.com", - ): - assert unity_rich_text_to_html(f'link') == "link" - - def test_non_finite_size_uses_default(self) -> None: - html = unity_rich_text_to_html(f"large") - assert html == 'large' - - def test_non_finite_indent_uses_default(self) -> None: - html = unity_rich_text_to_html(f"wide") - assert html == '
    wide
    ' - - def test_nested(self) -> None: - assert ( - unity_rich_text_to_html("bold italic") - == "bold italic" - ) - - def test_unknown_tag_ignored(self) -> None: - # Unknown tags should be stripped - assert unity_rich_text_to_html("bar") == "bar" - - def test_mixed_formatting(self) -> None: - assert ( - unity_rich_text_to_html("green") - == 'green' - ) diff --git a/tests/test_ui/test_views.py b/tests/test_ui/test_views.py deleted file mode 100644 index f4a98eb..0000000 --- a/tests/test_ui/test_views.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -from collections.abc import Iterator -from typing import TYPE_CHECKING - -import pytest -from PySide6.QtCore import QCoreApplication -from PySide6.QtQml import QQmlEngine -from PySide6.QtWidgets import QApplication, QWidget - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.plugins.steam_workshop import SteamWorkshopViewPanel -from pxmodrim.ui.theme.qml_theme import Theme - -if TYPE_CHECKING: - from pxmodrim.core.context import CoreContext - from pxmodrim.ui.context import AppContext - -pytestmark = pytest.mark.usefixtures("qapp") - - -@pytest.fixture(scope="module") -def qapp() -> Iterator[QApplication]: - app = QCoreApplication.instance() - if app is None: - app = QApplication([]) - assert isinstance(app, QApplication) - yield app - - -@pytest.fixture(scope="module") -def qml_engine(qapp: QApplication) -> Iterator[QQmlEngine]: - engine = create_qml_engine() - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - yield engine - - -@pytest.fixture -def parent_widget(qapp: QApplication) -> Iterator[QWidget]: - """A container the panel is embedded in, mirroring MainWindow's stack. - - Mirrors production wiring (AGENTS.md: never orphan Qt objects) and keeps - the panel's C++ objects alive for the duration of the test instead of - being torn down by Python GC while the shared QML engine still holds - bindings into them. - """ - widget = QWidget() - yield widget - widget.deleteLater() - - -def _ctx_stub(cfg_svc: ConfigService) -> CoreContext: - from pxmodrim.core.context import CoreContext - - return CoreContext.create(cfg_svc.load("config.json", AppConfig), cfg_svc) - - -def _app_ctx_stub(cfg_svc: ConfigService) -> AppContext: - from pxmodrim.ui.context import AppContext - - return AppContext(_ctx_stub(cfg_svc)) - - -class TestSteamWorkshopView: - def test_preload_runs_without_error( - self, - qml_engine: QQmlEngine, - config_service: ConfigService, - parent_widget: QWidget, - ) -> None: - view = SteamWorkshopViewPanel( - ctx=_ctx_stub(config_service), - qml_engine=qml_engine, - parent=parent_widget, - app_ctx=_app_ctx_stub(config_service), - ) - - view.preload() - - def test_web_is_none_before_init( - self, - qml_engine: QQmlEngine, - config_service: ConfigService, - parent_widget: QWidget, - ) -> None: - view = SteamWorkshopViewPanel( - ctx=_ctx_stub(config_service), - qml_engine=qml_engine, - parent=parent_widget, - app_ctx=_app_ctx_stub(config_service), - ) - - assert view._web() is None diff --git a/tests/test_ui/test_workshop_images.py b/tests/test_ui/test_workshop_images.py deleted file mode 100644 index 765a398..0000000 --- a/tests/test_ui/test_workshop_images.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import pytest -from PySide6.QtCore import QUrl -from PySide6.QtGui import QImage -from PySide6.QtQml import QJSEngine, QJSValue, QQmlEngine -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtWidgets import QApplication, QWidget -from pytestqt.qtbot import QtBot - -from pxmodrim.ui.plugins.workshop import view - - -@pytest.fixture -def thumbnails(qapp: QApplication) -> QJSValue: - path = Path(view.__file__).with_name("thumbs.js") - engine = QJSEngine(qapp) - engine.evaluate(path.read_text().removeprefix(".pragma library"), str(path)) - return engine.globalObject().property("sized") - - -@pytest.mark.parametrize( - "url", - [ - "https://example.com/image.png", - "https://notsteamstatic.com/image.png", - "https://steamusercontent.com.example.com/image.png", - "https://user@steamstatic.com/image.png", - "", - ], -) -def test_thumbnail_leaves_other_urls_unchanged(thumbnails: QJSValue, url: str) -> None: - assert ( - thumbnails.call([QJSValue(url), QJSValue(256), QJSValue(128)]).toString() == url - ) - - -@pytest.mark.parametrize( - "host", ["images.steamusercontent.com", "steamstatic.com", "cdn.steamstatic.com"] -) -def test_thumbnail_replaces_resize_query_before_fragment( - thumbnails: QJSValue, host: str -) -> None: - url = f"https://{host}/image.png?token=abc&imw=720&imh=360&ima=fit&impolicy=Letterbox#preview" - expected = f"https://{host}/image.png?token=abc&imw=256&imh=128&ima=fit&impolicy=Letterbox#preview" - resized = thumbnails.call([QJSValue(url), QJSValue(256), QJSValue(128)]).toString() - assert resized == expected - assert ( - thumbnails.call([QJSValue(resized), QJSValue(256), QJSValue(128)]).toString() - == expected - ) - - -def test_hidden_thumbnail_unloads_and_can_load_again( - qapp: QApplication, tmp_path: Path, qtbot: QtBot -) -> None: - image = QImage(64, 32, QImage.Format.Format_RGB32) - image.fill(0xFF00FF00) - image_path = tmp_path / "preview.png" - assert image.save(str(image_path)) - directory = QUrl.fromLocalFile(str(Path(view.__file__).parent)).toString() - source = tmp_path / "Image.qml" - source.write_text( - f'import QtQuick\nimport "{directory}" as Workshop\n' - f"Workshop.CatalogImage {{ previewUrl: {json.dumps(image_path.as_uri())} }}", - ) - owner = QWidget() - engine = QQmlEngine(owner) - warnings: list[str] = [] - engine.warnings.connect( - lambda errors: warnings.extend(error.toString() for error in errors) - ) - widget = QQuickWidget(engine, owner) - widget.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - widget.resize(200, 110) - widget.setSource(QUrl.fromLocalFile(str(source))) - owner.show() - widget.show() - root = widget.rootObject() - assert root is not None - handle = engine.newQObject(root) - - def wait_for_status(expected: int) -> None: - qtbot.waitUntil(lambda: handle.property("status").toInt() == expected) - - try: - wait_for_status(1) - root.setVisible(False) - wait_for_status(0) - root.setVisible(True) - widget.resize(300, 150) - wait_for_status(1) - assert widget.grabFramebuffer().pixelColor(150, 75).name() == "#00ff00" - assert not warnings, warnings - finally: - widget.setSource(QUrl()) - owner.close() - owner.deleteLater() diff --git a/tests/test_ui/test_workshop_installed.py b/tests/test_ui/test_workshop_installed.py deleted file mode 100644 index f047cab..0000000 --- a/tests/test_ui/test_workshop_installed.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.workshop import Author, CatalogMod -from pxmodrim.ui.plugins.workshop.installed import PAGE, InstalledList - - -def _mod( - id: str, title: str, author: str = "Someone", tags: list[str] | None = None -) -> CatalogMod: - return CatalogMod( - id=id, - source="steam", - title=title, - author=Author(None, author, None), - description="", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url="", - tags=tags or [], - supported_versions=[], - created_at=None, - updated_at=None, - file_size=None, - subscriptions=None, - votes=None, - dependencies=[], - package_id=None, - incompatible=False, - ) - - -def test_search_matches_title_author_id_and_tags_case_insensitively() -> None: - installed = InstalledList() - installed.replace( - [ - _mod("11", "Harmony", "Brrainz"), - _mod("12", "HugsLib", tags=["Library"]), - _mod("999", "Other"), - ] - ) - for query, expected in [ - ("harm", ["11"]), - ("BRRAINZ", ["11"]), - ("library", ["12"]), - ("999", ["999"]), - ("", ["11", "12", "999"]), - ("nothing", []), - ]: - installed.search(query) - assert [m.id for m in installed.visible()] == expected, query - - -def test_paging_reveals_one_page_at_a_time_and_search_restarts_it() -> None: - installed = InstalledList() - base = _mod("0", "Mod") - installed.replace( - [msgspec.structs.replace(base, id=str(i)) for i in range(PAGE * 2 + 5)] - ) - assert len(installed.visible()) == PAGE and installed.has_more - second = installed.next_page() - assert len(second) == PAGE and second[0].id == str(PAGE) - assert len(installed.next_page()) == 5 and not installed.has_more - assert installed.next_page() == [] - installed.search("") - assert len(installed.visible()) == PAGE and installed.total == PAGE * 2 + 5 diff --git a/tests/test_ui/test_workshop_models.py b/tests/test_ui/test_workshop_models.py deleted file mode 100644 index 1a6592d..0000000 --- a/tests/test_ui/test_workshop_models.py +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -import msgspec -import pytest -from PySide6.QtCore import QModelIndex, QObject, Qt - -from pxmodrim.core.workshop import Author, CatalogCollection, CatalogMod, Preview, Votes -from pxmodrim.ui.panels.mod_info_data import description_to_html -from pxmodrim.ui.plugins.workshop.models import ( - CatalogListModel, - item_row, - safe_url, - summarize, -) - - -@pytest.mark.parametrize( - "url", - [ - "javascript:alert(1)", - "file:///tmp/test", - "data:text/plain,hi", - "//example.com", - "https:///path", - "https://example.com/\nsecret", - "https://[bad", - ], -) -def test_link_scheme_filter_rejects_unsafe_urls(url: str) -> None: - assert safe_url(url) == "" - - -@pytest.mark.parametrize( - "url", ["https://example.com/path", "http://example.com", "HTTPS://example.com"] -) -def test_link_scheme_filter_accepts_web_urls(url: str) -> None: - assert safe_url(url) == url - - -def _mod(pid: str) -> CatalogMod: - return CatalogMod( - id=pid, - source="steam", - title=f"Mod {pid}", - author=Author("1", "Author", None), - description="[b]Description[/b]", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url=f"https://steamcommunity.com/sharedfiles/filedetails/?id={pid}", - tags=["Utility"], - supported_versions=["1.5"], - created_at=None, - updated_at=100, - file_size="1048576", - subscriptions=None, - votes=None, - dependencies=[], - package_id=None, - incompatible=False, - ) - - -def test_roles_paging_append_and_reset(qapp: object) -> None: - owner = QObject() - model = CatalogListModel(owner) - rows = [item_row(_mod(pid), lambda _: "outdated", "1.6") for pid in ["1", "2", "3"]] - model.set_page(rows[:2], 10, "page2") - assert model.hasMore and model.count == 2 and model.total == 10 - names = {bytes(name.data()).decode() for name in model.roleNames().values()} - assert names == set(rows[0]) - role = next( - role - for role, name in model.roleNames().items() - if bytes(name.data()) == b"itemId" - ) - assert model.data(model.index(0), role) == "1" - model.set_page(rows[1:], 10, None, append=True) - assert model.count == 3 and not model.hasMore - assert model.data(model.index(2), role) == "3" - assert model.data(QModelIndex(), role) is None - assert model.data(model.index(0), Qt.ItemDataRole.DisplayRole) is None - assert model.rowCount(model.index(0)) == 0 - model.set_page([], 0, None) - assert model.count == 0 and model.total == 0 - - -def test_refreshing_the_same_page_updates_changed_rows_without_reset( - qapp: object, -) -> None: - owner = QObject() - model = CatalogListModel(owner) - mods = [_mod(pid) for pid in ["1", "2", "3"]] - - def show(items: list[CatalogMod]) -> None: - rows = [item_row(m, lambda _: "missing", "1.5") for m in items] - model.set_page(rows, 3, None) - - show(mods) - resets: list[bool] = [] - changed: list[int] = [] - model.modelReset.connect(lambda: resets.append(True)) - model.dataChanged.connect(lambda first, _last: changed.append(first.row())) - - mods[0] = msgspec.structs.replace(mods[0], subscriptions=500) - mods[1] = msgspec.structs.replace(mods[1], title="Renamed") - show(mods) - assert resets == [] and changed == [1] - title = next(r for r, n in model.roleNames().items() if bytes(n.data()) == b"title") - assert model.data(model.index(1), title) == "Renamed" - - model.set_page( - [item_row(m, lambda _: "missing", "1.5") for m in mods[::-1]], 3, None - ) - assert resets == [True] - - -def test_row_shows_update_and_game_incompatibility() -> None: - row = item_row(_mod("1"), lambda _: "outdated", "1.6") - assert row["actionLabel"] == "Update" and row["incompatible"] - assert row["fileSize"] == "1.0 MB" - - -@pytest.mark.parametrize( - ("votes", "approval", "up", "down"), - [ - (None, "Unrated", "0", "0"), - (Votes(0, 0, 100), "Unrated", "0", "0"), - (Votes(0, 1, None), "0%", "0", "1"), - (Votes(1, 0, None), "100%", "1", "0"), - (Votes(1, 2, None), "33%", "1", "2"), - (Votes(2, 1, None), "67%", "2", "1"), - (Votes(1_000, 3_000, None), "25%", "1,000", "3,000"), - ], -) -def test_vote_approval_and_counts( - votes: Votes | None, approval: str, up: str, down: str -) -> None: - mod = msgspec.structs.replace(_mod("1"), votes=votes) - row = item_row(mod, lambda _: "installed", "1.5") - assert (row["votes"], row["votesUp"], row["votesDown"]) == (approval, up, down) - - -@pytest.mark.parametrize("description_format", ["bbcode", "text"]) -def test_descriptions_use_shared_mod_info_renderer(description_format: str) -> None: - mod = msgspec.structs.replace(_mod("1"), description_format=description_format) - row = item_row(mod, lambda _: "missing", "1.5", detail=True) - assert row["description"] == description_to_html( - mod.description, description_format - ) - - -def test_versions_are_unique_numeric_sorted_and_not_repeated_in_tags() -> None: - mod = _mod("1") - mod = msgspec.structs.replace( - mod, - supported_versions=["1.5", "1.10", "1.2", "1.5", "1.2.1"], - tags=["Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"], - ) - row = item_row(mod, lambda _: "missing", "1.5") - assert row["versions"] == "1.2, 1.2.1, 1.5, 1.10" - assert row["tags"] == "Mod · Utility" - - -def test_collection_image_prefers_own_then_first_image_then_member_collage() -> None: - bare = CatalogCollection( - id="steam:1", - source="steam", - steam_id="1", - title="C", - author=Author("1", "A", None), - description="", - description_format="bbcode", - preview_url=None, - previews=[ - Preview("video", "https://youtube.test/v"), - Preview("image", "https://images.test/own.png"), - ], - workshop_url=None, - tags=[], - supported_versions=[], - created_at=None, - updated_at=None, - member_ids=["10"], - member_count=1, - member_previews=["https://images.test/m.png", "javascript:alert(1)"], - ) - row = item_row(bare, lambda _: "missing", "1.6") - assert row["previewUrl"] == "https://images.test/own.png" - assert row["collage"] == ["https://images.test/m.png"] - assert (row["votes"], row["votesUp"], row["votesDown"]) == ("Unrated", "0", "0") - assert row["active"] is False - - -def test_summary_is_plain_text_without_bbcode_or_media() -> None: - text = ( - "[h1]Title[/h1] [img]https://x.test/a.png[/img] " - "Adds [url=https://x.test]a thing[/url]" - ) - assert summarize(text) == "Title Adds a thing" - assert summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept" - - -def test_summary_truncates_on_a_word_boundary() -> None: - summary = summarize("word " * 100) - assert summary.endswith("word…") - assert len(summary) <= 141 - - -@pytest.mark.parametrize( - ("supported", "game", "incompatible", "label"), - [ - (["1.5"], "1.5", False, "Works with 1.5"), - (["1.4"], "1.5", True, "Not for 1.5"), - ([], "1.5", False, "Version not stated"), - (["1.5"], "", False, ""), - ], -) -def test_compat_label( - supported: list[str], game: str, incompatible: bool, label: str -) -> None: - mod = msgspec.structs.replace( - _mod("1"), supported_versions=supported, incompatible=incompatible - ) - assert item_row(mod, lambda _: "missing", game)["compatLabel"] == label - - -def test_collection_size_comes_from_the_server_and_is_hidden_when_unknown() -> None: - collection = CatalogCollection( - id="picked:x", - source="picked", - steam_id=None, - title="C", - author=Author(None, "Curator", None), - description="", - description_format="text", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=[], - created_at=None, - updated_at=None, - member_ids=["1"], - member_count=1, - ) - unknown = item_row(collection, lambda _: "missing", "1.6") - sized = item_row( - msgspec.structs.replace(collection, total_size=str(3 * 1048576)), - lambda _: "missing", - "1.6", - ) - assert unknown["fileSize"] == "Unknown" - assert sized["fileSize"] == "3.0 MB" - - -def test_collection_without_a_common_version_is_incompatible_not_unstated() -> None: - collection = CatalogCollection( - id="steam:1", - source="steam", - steam_id="1", - title="C", - author=Author(None, "Curator", None), - description="", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=[], - created_at=None, - updated_at=None, - member_ids=["1", "2"], - member_count=2, - no_common_version=True, - ) - row = item_row(collection, lambda _: "missing", "1.6") - assert row["compatLabel"] == "No common version" - assert row["incompatible"] is True - unknown = item_row( - msgspec.structs.replace(collection, no_common_version=False), - lambda _: "missing", - "1.6", - ) - assert unknown["compatLabel"] == "Version not stated" - assert unknown["incompatible"] is False - - -@pytest.mark.parametrize( - ("supported_versions", "tags"), - [ - ([], ["Mod", "1.6"]), - (["1.5"], ["Mod", "1.6"]), - (["1.6"], ["Mod"]), - ], -) -def test_collection_declarations_override_conflicting_member_summary( - supported_versions: list[str], tags: list[str] -) -> None: - collection = CatalogCollection( - id="steam:3814118422", - source="steam", - steam_id="3814118422", - title="12", - author=Author(None, "Milka", None), - description="", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url=None, - tags=tags, - supported_versions=supported_versions, - created_at=None, - updated_at=None, - member_ids=["1", "2"], - member_count=2, - no_common_version=True, - ) - row = item_row(collection, lambda _: "missing", "1.6") - assert row["incompatible"] is False - assert row["compatLabel"] == "Works with 1.6" - assert "1.6" in row["versions"] - assert row["tags"] == "Mod" diff --git a/tests/test_ui/test_workshop_view.py b/tests/test_ui/test_workshop_view.py deleted file mode 100644 index bdbed93..0000000 --- a/tests/test_ui/test_workshop_view.py +++ /dev/null @@ -1,1443 +0,0 @@ -from __future__ import annotations - -import asyncio -from collections.abc import AsyncIterator -from pathlib import Path -from types import SimpleNamespace -from typing import Any, cast - -import msgspec -import pytest -from PySide6.QtCore import QObject, QPoint, Qt, QUrl -from PySide6.QtGui import QAccessible, QKeySequence -from PySide6.QtQuick import QQuickItem -from PySide6.QtQuickWidgets import QQuickWidget -from PySide6.QtTest import QTest -from PySide6.QtWidgets import QWidget -from pytestqt.qtbot import QtBot -from shiboken6 import delete, isValid - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads import DownloadManager -from pxmodrim.core.models.metadata.structures import ( - AboutXmlMod, - BaseRules, - CaseInsensitiveStr, - DependencyMod, - ListedMod, -) -from pxmodrim.core.workshop import ( - Author, - CatalogCollection, - CatalogError, - CatalogMod, - CatalogPage, - CatalogQuery, - CollectionDetail, - Discover, - DownloadPlan, - InstallState, - Preview, - WorkshopCatalog, -) -from pxmodrim.ui.components import create_qml_engine -from pxmodrim.ui.context import AppContext -from pxmodrim.ui.navigation import parse_route -from pxmodrim.ui.panels.mod_info_data import description_to_html -from pxmodrim.ui.plugins.workshop.plugin import CatalogSettingsSection -from pxmodrim.ui.plugins.workshop.view import WorkshopViewPanel -from pxmodrim.ui.theme.qml_theme import Theme - - -def _mod(pid: str, dependencies: list[str] | None = None) -> CatalogMod: - return CatalogMod( - id=pid, - source="steam", - title="Untrusted title", - author=Author("1", "Author", None), - description="[b]Safe description[/b]", - description_format="bbcode", - preview_url=None, - previews=[], - workshop_url="https://steamcommunity.com/sharedfiles/filedetails/?id=" + pid, - tags=["Utility"], - supported_versions=["1.5"], - created_at=None, - updated_at=100, - file_size="1024", - subscriptions=None, - votes=None, - dependencies=dependencies or [], - package_id=None, - incompatible=False, - ) - - -class FakeCatalog(WorkshopCatalog): - def __init__(self, ctx: CoreContext) -> None: - super().__init__() - self.mod_item = _mod("1", ["2"]) - self.dependency = _mod("2") - self.collection_item = CatalogCollection( - id="picked:test", - source="picked", - steam_id=None, - title="Test collection", - author=Author(None, "Curator", None), - description="Plain text", - description_format="text", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=["1.6"], - created_at=None, - updated_at=None, - member_ids=["1", "missing"], - member_count=2, - ) - self.failure = False - self.collection_complete = False - self.downloaded: list[str] = [] - self.queries: list[CatalogQuery] = [] - - async def discover(self) -> Discover: - return Discover( - CatalogPage([self.mod_item], 1, None), - CatalogPage([self.collection_item], 1, None), - ) - - async def mods(self, q: CatalogQuery) -> CatalogPage[CatalogMod]: - self.queries.append(q) - if self.failure: - raise CatalogError("Catalog unavailable", 503) - return CatalogPage( - [self.dependency] if q.cursor else [self.mod_item], - 2, - None if q.cursor else "second", - ) - - async def collections(self, q: CatalogQuery) -> CatalogPage[CatalogCollection]: - return CatalogPage([self.collection_item], 1, None) - - async def mod(self, id: str) -> CatalogMod | None: - return {"1": self.mod_item, "2": self.dependency}.get(id) - - async def collection(self, id: str) -> CollectionDetail | None: - if self.collection_complete: - collection = msgspec.structs.replace( - self.collection_item, member_ids=["1"], member_count=1 - ) - return CollectionDetail(collection, [self.mod_item], [], True) - return CollectionDetail( - self.collection_item, [self.mod_item], ["missing"], False - ) - - async def installed_with_updates(self) -> list[CatalogMod]: - return [self.mod_item] - - def install_state(self, mod: CatalogMod) -> InstallState: - return "outdated" if mod.id == "1" else "missing" - - async def plan(self, mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - incomplete = bool(collection_ids) and not self.collection_complete - return DownloadPlan( - ["2", "1"], - [], - ["missing"] if incomplete else [], - collection_ids if incomplete else [], - {"1": "Mod", "2": "Dependency"}, - not incomplete, - ) - - def enqueue(self, plan: DownloadPlan) -> None: - self.downloaded.extend(plan.to_download) - self._queued.update(plan.to_download) - self.queue_changed.emit(None) - - -@pytest.fixture -async def panel( - qapp: object, config_service: ConfigService -) -> AsyncIterator[tuple[WorkshopViewPanel, FakeCatalog, list[Any]]]: - owner = QWidget() - ctx = CoreContext.create(AppConfig(), config_service) - manager = DownloadManager() - ctx.plugins.register(manager) - catalog = FakeCatalog(ctx) - engine = create_qml_engine() - engine.setParent(owner) - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - warnings: list[Any] = [] - - def _on_warning(qml_warnings: list[Any]) -> None: - for w in qml_warnings: - msg = w.toString() if hasattr(w, "toString") else str(w) - if "The current style does not support customization" in msg: - continue - warnings.append(w) - - engine.warnings.connect(_on_warning) - view = WorkshopViewPanel(ctx, engine, owner, catalog=catalog) - view.resize(1100, 800) - yield view, catalog, warnings - if isValid(view): - view.teardown() - view._qml.setSource(QUrl()) - owner.deleteLater() - - -@pytest.fixture -def installed_mods(tmp_path: Path) -> dict[str, ListedMod]: - mods: dict[str, ListedMod] = {} - for provider, pid in [("steamcmd", "1"), ("steam", "1"), ("steam", "2")]: - mod = AboutXmlMod( - name=f"Installed {pid}", - package_id=CaseInsensitiveStr(f"test.mod{pid}"), - provider_id=provider, - _mod_path=tmp_path / provider / pid, - ) - mods[mod.uuid] = mod - return mods - - -async def test_view_loads_qml_and_discovery_without_errors( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, _, warnings = panel - await asyncio.sleep(0) - assert view._qml.status() == QQuickWidget.Status.Ready - assert view.mods_model.count == 1 and view.collections_model.count == 1 - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_destroyed_view_does_not_receive_core_shutdown_events( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await view.load() - model = view.mods_model - assert model.count == 1 - notifications: list[None] = [] - catalog.queue_changed.connect(notifications.append) - downloads = view._downloads - ctx = view._ctx - view._qml.setSource(QUrl()) - delete(view) - await catalog.shutdown() - downloads.busy_changed.emit(False) - catalog.installed_changed.emit(None) - catalog.catalog_url_changed.emit("") - ctx.active_state_changed.emit(()) - assert not isValid(model) - assert notifications == [None] - - -async def test_teardown_discards_load_finishing_after_view_destruction( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - await view.load() - started, release = asyncio.Event(), asyncio.Event() - discover = catalog.discover - - async def delayed_discover() -> Discover: - started.set() - await release.wait() - return await discover() - - monkeypatch.setattr(catalog, "discover", delayed_discover) - catalog._responses.clear() - task = asyncio.ensure_future(view.refresh()) - async with asyncio.timeout(3): - await started.wait() - view.teardown() - view.teardown() - model = view.mods_model - view._qml.setSource(QUrl()) - delete(view) - release.set() - await task - assert not isValid(model) - - -async def test_view_paging_filters_and_catalog_errors( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - await view.select_tab("Mods") - await view.filter("", "1.6", "all", "popular", "") - await view.loadMore() - assert view.mods_model.count == 2 and not view.mods_model.hasMore - assert ( - catalog.queries[-1].cursor == "second" and catalog.queries[-1].version == "1.6" - ) - catalog.failure = True - await view.filter("uncached", "1.6", "all", "popular", "") - assert view.error == "Catalog unavailable" - catalog.failure = False - await view.retry() - assert view.error == "" and view.mods_model.count == 1 - - -async def test_mod_detail_safe_description_dependencies_and_download( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, warnings = panel - await asyncio.sleep(0) - await view.open_item("1", "mod") - detail = cast("dict[str, Any]", view.detail) - assert detail["description"] == description_to_html( - catalog.mod_item.description, catalog.mod_item.description_format - ) - assert detail["actionLabel"] == "Update" - assert view.members_model.count == 1 - await view.downloadItem("1", "mod") - assert catalog.downloaded == ["2", "1"] - assert view._ctx.active_uuids == [] - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_incomplete_collection_requires_explicit_partial_download( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, warnings = panel - await asyncio.sleep(0) - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert not detail["complete"] and "missing" in detail["warning"] - assert detail["description"] == description_to_html( - catalog.collection_item.description, catalog.collection_item.description_format - ) - await view.downloadItem("picked:test", "collection") - assert view.pendingPlan and not catalog.downloaded - assert view.canDownloadAvailable - await view.downloadAvailable() - assert catalog.downloaded == ["2", "1"] and not view.pendingPlan - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_collection_detail_uses_member_images_when_preview_metadata_is_absent( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - catalog.mod_item = msgspec.structs.replace( - catalog.mod_item, - preview_url=None, - previews=[Preview("image", "https://images.test/member.png")], - ) - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert detail["collage"] == ["https://images.test/member.png"] - - -@pytest.mark.parametrize("tab", ["Discover", "Collections", "Favourites"]) -@pytest.mark.parametrize( - ("installed_count", "state", "action_label"), - [ - (0, "missing", "Download all"), - (1, "missing", "Complete download"), - (2, "installed", "Installed"), - ], -) -async def test_collection_listing_detects_installed_members_without_opening_detail( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - tab: str, - installed_count: int, - state: str, - action_label: str, -) -> None: - view, catalog, _ = panel - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1", "2"] - ) - catalog._responses.clear() - installed_ids = catalog.collection_item.member_ids[:installed_count] - view._ctx.load( - { - uuid: mod - for uuid, mod in installed_mods.items() - if mod.published_file_id in installed_ids - }, - [], - ) - await view.load() - if tab == "Favourites": - view.toggleFavourite(catalog.collection_item.id) - await view.select_tab(tab) - - row = view.collections_model._rows[0] - assert row["state"] == state - assert row["actionLabel"] == action_label - assert not view.hasDetail - - -async def test_collection_listing_refreshes_when_installed_members_change( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1", "2"] - ) - catalog._responses.clear() - await view.select_tab("Collections") - assert view.collections_model._rows[0]["actionLabel"] == "Download all" - - view._ctx.load(installed_mods, []) - await view._on_installed_changed(None) - row = view.collections_model._rows[0] - assert row["state"] == "installed" - assert row["actionLabel"] == "Installed" - - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - await view._on_installed_changed(None) - row = view.collections_model._rows[0] - assert row["state"] == "missing" - assert row["actionLabel"] == "Complete download" - - view._ctx.load({}, []) - await view._on_installed_changed(None) - assert view.collections_model._rows[0]["actionLabel"] == "Download all" - assert not view.hasDetail - - -@pytest.mark.parametrize("fully_installed", [False, True]) -async def test_collection_listing_progress_overrides_installed_member_labels( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, - fully_installed: bool, -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1", "2"] - ) - catalog._responses.clear() - steamcmd, _, _ = installed_mods - view._ctx.load( - installed_mods if fully_installed else {steamcmd: installed_mods[steamcmd]}, - [], - ) - await view.load() - release = asyncio.Event() - plan = catalog.plan - - async def gated_plan(mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - await release.wait() - return await plan(mod_ids, collection_ids) - - monkeypatch.setattr(catalog, "plan", gated_plan) - planning = asyncio.ensure_future(view._download([], [catalog.collection_item.id])) - try: - await asyncio.sleep(0) - row = view.collections_model._rows[0] - assert row["queued"] and row["actionLabel"] == "Preparing…" - finally: - release.set() - await planning - row = view.collections_model._rows[0] - assert row["queued"] and row["actionLabel"] == "Queued" - catalog.cancel_queue() - assert view.collections_model._rows[0]["actionLabel"] == ( - "Installed" if fully_installed else "Complete download" - ) - - -async def test_installed_collection_listing_and_resolved_detail_agree( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, member_ids=["1"], member_count=1 - ) - catalog._responses.clear() - view._ctx.load(installed_mods, []) - await view.load() - listing = view.collections_model._rows[0] - assert listing["state"] == "installed" - assert listing["actionLabel"] == "Installed" - - await view.open_item(catalog.collection_item.id, "collection") - detail = cast("dict[str, Any]", view.detail) - assert detail["state"] == listing["state"] - assert detail["actionLabel"] == listing["actionLabel"] - - -async def test_collection_download_button_tracks_its_queued_mods( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - await view.load() - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert not detail["queued"] - release = asyncio.Event() - plan = catalog.plan - - async def gated_plan(mod_ids: list[str], collection_ids: list[str]) -> DownloadPlan: - await release.wait() - return await plan(mod_ids, collection_ids) - - monkeypatch.setattr(catalog, "plan", gated_plan) - planning = asyncio.ensure_future(view._download([], ["picked:test"])) - await asyncio.sleep(0) - detail = cast("dict[str, Any]", view.detail) - assert detail["queued"] and detail["actionLabel"] == "Preparing…" - release.set() - await planning - detail = cast("dict[str, Any]", view.detail) - assert detail["queued"] and detail["actionLabel"] == "Queued" - model = view.collections_model - queued = next( - role - for role, name in model.roleNames().items() - if bytes(name.data()) == b"queued" - ) - assert model.data(model.index(0), queued) is True - catalog.cancel_queue() - detail = cast("dict[str, Any]", view.detail) - assert not detail["queued"] and detail["actionLabel"] == "Complete download" - - -async def test_unset_catalog_never_fetches( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - catalog.set_base_url("") - await view.load() - assert not view.configured and not catalog.queries - - -async def test_settings_saves_and_applies_catalog_url( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - from pxmodrim.ui.panels.settings_panel import SettingsPanel - - view, catalog, warnings = panel - await asyncio.sleep(0) - settings = SettingsPanel( - view._ctx, - view._qml.engine(), - view, - [lambda parent: CatalogSettingsSection(catalog.settings, parent)], - ) - settings.show() - await asyncio.sleep(0.2) - root = settings._qml.rootObject() - assert root is not None - field = _find_item(cast("QQuickItem", root), "catalogUrlField") - assert field is not None - assert field.property("text") == "https://api.modrim.pyxiion.dev" - field.forceActiveFocus() - QTest.keySequence(settings._qml, QKeySequence.StandardKey.SelectAll) - QTest.keyClicks(settings._qml, " https://catalog.example.test/ ") - assert catalog.configured and catalog._base_url != "https://catalog.example.test" - - settings._save(dict(cast("dict[str, Any]", settings._backend.initial))) - assert catalog.settings.value.url == "https://catalog.example.test/" - assert catalog._base_url == "https://catalog.example.test" - QTest.qWait(10) - delete(settings) - assert warnings == [] - - -async def test_installed_search_filters_loaded_mods_without_refetching( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - fetches = 0 - base = catalog.mod_item - mods = [ - msgspec.structs.replace(base, id=str(i), title=title) - for i, title in enumerate(["Harmony", "HugsLib", "Rim HUD"]) - ] - - async def installed() -> list[CatalogMod]: - nonlocal fetches - fetches += 1 - return mods - - catalog.installed_with_updates = installed # type: ignore[method-assign] - await view.select_tab("Installed") - assert view.mods_model.count == 3 and fetches == 1 - - await view.filter("hugs", "", "all", "popular", "") - assert view.mods_model.count == 1 and view.tab == "Installed" and fetches == 1 - await view.filter("", "", "all", "popular", "") - assert view.mods_model.count == 3 and fetches == 1 - assert view.searchQuery == "" - - -async def test_installed_set_changes_refresh_the_cached_list( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - tmp_path: Path, -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - catalog._cache_dir = tmp_path / "cache" - steamcmd, _, dependency = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - fetches = 0 - - async def installed() -> list[CatalogMod]: - nonlocal fetches - fetches += 1 - items = {"1": catalog.mod_item, "2": catalog.dependency} - return [items[pid] for pid in catalog._installed()] - - catalog.installed_with_updates = installed # type: ignore[method-assign] - await view.select_tab("Installed") - await view.select_tab("Mods") - await view.select_tab("Installed") - assert fetches == 1 and view.mods_model.count == 1 - - catalog.installed_changed.disconnect(view._on_installed_changed) - view._ctx.load(installed_mods, []) - catalog._on_installed_changed(None) - catalog.installed_changed.connect(view._on_installed_changed) - await view._on_installed_changed(None) - assert fetches == 2 and view.mods_model.count == 2 - assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} - - catalog.installed_changed.disconnect(view._on_installed_changed) - view._ctx.load({dependency: installed_mods[dependency]}, []) - catalog._on_installed_changed(None) - catalog.installed_changed.connect(view._on_installed_changed) - await view._on_installed_changed(None) - assert fetches == 3 and view.mods_model.count == 1 - assert view.mods_model._rows[0]["itemId"] == "2" - - catalog._responses.clear() - await view.select_tab("Mods") - await view.select_tab("Installed") - assert fetches == 3 and view.mods_model._rows[0]["itemId"] == "2" - - -@pytest.mark.parametrize("state", ["installed", "outdated"]) -@pytest.mark.parametrize("duplicate_active", [False, True]) -async def test_activation_toggles_one_installed_copy_and_prefers_active_steam_copy( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, - state: InstallState, - duplicate_active: bool, -) -> None: - view, catalog, _ = panel - steamcmd, steam, unrelated = installed_mods - view._ctx.load(installed_mods, []) - view._ctx.set_active( - [steamcmd, steam, unrelated] if duplicate_active else [steam, unrelated] - ) - monkeypatch.setattr(catalog, "install_state", lambda _: state) - await view.load() - await view.open_item("1", "mod") - role = next( - role - for role, name in view.mods_model.roleNames().items() - if bytes(name.data()) == b"active" - ) - assert view.mods_model.data(view.mods_model.index(0), role) is True - assert cast("dict[str, Any]", view.detail)["active"] is True - expected_action = "Update" if state == "outdated" else "Installed" - assert cast("dict[str, Any]", view.detail)["actionLabel"] == expected_action - - await view.toggleActivation("1") - assert view._ctx.active_uuids == [unrelated] - assert view.mods_model.data(view.mods_model.index(0), role) is False - assert cast("dict[str, Any]", view.detail)["active"] is False - - await view.toggleActivation("1") - assert set(view._ctx.active_uuids) == {steamcmd, unrelated} - assert steam not in view._ctx.active_uuids - assert view.mods_model.data(view.mods_model.index(0), role) is True - assert cast("dict[str, Any]", view.detail)["active"] is True - - -async def test_external_activation_refreshes_listing_detail_and_members( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, _, _ = panel - _, steam, dependency = installed_mods - view._ctx.load(installed_mods, []) - await view.load() - await view.open_item("1", "mod") - role = next( - role - for role, name in view.mods_model.roleNames().items() - if bytes(name.data()) == b"active" - ) - - for active, expected in [([steam, dependency], True), ([], False)]: - view._ctx.set_active(active) - assert view.mods_model.data(view.mods_model.index(0), role) is expected - assert view.members_model.data(view.members_model.index(0), role) is expected - assert cast("dict[str, Any]", view.detail)["active"] is expected - - -async def test_download_never_activates_installed_copies( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - view._ctx.load(installed_mods, []) - await view.downloadItem("1", "mod") - assert catalog.downloaded == ["2", "1"] - assert view._ctx.active_uuids == [] - - -def _find_item(item: QQuickItem, name: str) -> QQuickItem | None: - if item.objectName() == name: - return item - for child in item.childItems(): - found = _find_item(child, name) - if found is not None: - return found - return None - - -async def test_installed_checkbox_click_toggles_activation_without_opening_details( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - view.window().resize(1100, 800) - view.window().show() - await view.select_tab("Installed") - await asyncio.sleep(0.5) - view._qml.grabFramebuffer() - await asyncio.sleep(0.2) - - root = view._qml.rootObject() - assert root is not None - box = _find_item(cast("QQuickItem", root), "activeToggle") - assert box is not None - center = box.mapToScene(box.boundingRect().center()).toPoint() - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=center) - async with asyncio.timeout(3): - while not view._ctx.active_uuids: - await asyncio.sleep(0.02) - assert view._ctx.active_uuids == [steamcmd] - assert not view.hasDetail - - -async def test_clicking_an_installed_row_navigates_by_link( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - links: list[str] = [] - - def navigate(url: str) -> None: - links.append(url) - route = parse_route(url) - assert route is not None - view.open_route(route.path) - - view._app_ctx = cast("AppContext", SimpleNamespace(navigate=navigate)) - view.window().resize(1100, 800) - view.window().show() - await view.select_tab("Installed") - await asyncio.sleep(0.5) - view._qml.grabFramebuffer() - await asyncio.sleep(0.2) - - root = view._qml.rootObject() - assert root is not None - box = _find_item(cast("QQuickItem", root), "activeToggle") - assert box is not None - beside = box.mapToScene(box.boundingRect().center()).toPoint() - beside.setX(beside.x() + 150) - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=beside) - async with asyncio.timeout(3): - while not view.hasDetail: - await asyncio.sleep(0.02) - assert links == ["modrim://workshop/mod/1"] - assert view.url == "modrim://workshop/mod/1" - assert not view._ctx.active_uuids - - -async def test_activation_does_nothing_without_an_installed_copy( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await view.toggleActivation("1") - assert view._ctx.active_uuids == [] - assert catalog.downloaded == [] - - -async def test_teardown_disconnects_activation_and_blocks_toggles( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, _, _ = panel - _, steam, _ = installed_mods - view._ctx.load(installed_mods, []) - await view.load() - await view.open_item("1", "mod") - notifications: list[bool] = [] - view.changed.connect(lambda: notifications.append(True)) - view.teardown() - view.teardown() - view._ctx.set_active([steam]) - await view.toggleActivation("1") - assert view._ctx.active_uuids == [steam] - assert not notifications - assert cast("dict[str, Any]", view.detail)["active"] is False - - -async def test_known_tag_options_accumulate_loaded_tags_but_not_version_tags( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - catalog.mod_item = msgspec.structs.replace( - catalog.mod_item, tags=["Utility", "1.5"] - ) - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, tags=["Curated", "1.6"] - ) - catalog.dependency = msgspec.structs.replace( - catalog.dependency, tags=["Dependency", "1.5"] - ) - catalog._responses.clear() - await view.load() - assert view.tagOptions == ["All tags", "Curated", "Utility"] - await view.filter("utility", "1.5", "all", "popular", "Utility") - assert view.searchQuery == "utility" - assert view.tagOptions == ["All tags", "Curated", "Utility"] - await view.open_item("1", "mod") - assert view.tagOptions == ["All tags", "Curated", "Dependency", "Utility"] - await view.select_tab("Installed") - assert view.searchQuery == "utility" - - -async def test_back_walks_detail_history_before_returning_to_listing( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await view.select_tab("Mods") - await view.open_item("1", "mod") - await view.open_item("2", "mod") - assert view.url == "modrim://workshop/mod/2" - assert view.backLabel == f"Back to {catalog.mod_item.title}" - - await view.back() - assert cast("dict[str, Any]", view.detail)["itemId"] == "1" - assert view.url == "modrim://workshop/mod/1" - assert view.backLabel == "Back to Mods" - - await view.back() - assert not view.hasDetail - assert view.url == "modrim://workshop/mods" - - -async def test_failed_back_keeps_the_previous_page_for_retry( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - await view.open_item("1", "mod") - await view.open_item("2", "mod") - catalog._responses.clear() - working = catalog.mod - - async def unavailable(id: str) -> CatalogMod | None: - raise CatalogError("Catalog unavailable", 503) - - monkeypatch.setattr(catalog, "mod", unavailable) - await view.back() - assert view.url == "modrim://workshop/mod/2" - assert view.backLabel == f"Back to {catalog.mod_item.title}" - - monkeypatch.setattr(catalog, "mod", working) - await view.back() - assert view.url == "modrim://workshop/mod/1" - - -async def test_routes_open_tabs_and_items_like_links( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, _, _ = panel - await view.open_item("1", "mod") - await view.open_item("2", "mod") - - await view.follow_route(("collection", "picked:test")) - assert view.url == "modrim://workshop/collection/picked%3Atest" - await view.back() - assert view.url == "modrim://workshop/mod/2" - - await view.follow_route(("installed",)) - assert view.tab == "Installed" - assert view.url == "modrim://workshop/installed" - - await view.follow_route(()) - assert view.url == "modrim://workshop/installed" - - -async def test_starred_collection_is_listed_in_favourites_until_unstarred( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, _, warnings = panel - view.window().resize(1100, 800) - view.window().show() - await view.follow_route(("collections",)) - await asyncio.sleep(0.5) - view._qml.grabFramebuffer() - await asyncio.sleep(0.2) - root = view._qml.rootObject() - assert root is not None - star = _find_item(cast("QQuickItem", root), "favouriteToggle") - assert star is not None - center = star.mapToScene(star.boundingRect().center()).toPoint() - QTest.mouseClick(view._qml, Qt.MouseButton.LeftButton, pos=center) - assert not view.hasDetail - - await view.follow_route(("favourites",)) - model = view.collections_model - assert view.url == "modrim://workshop/favourites" - assert model.count == 1 - favourite = next( - role - for role, name in model.roleNames().items() - if bytes(name.data()) == b"favourite" - ) - assert model.data(model.index(0), favourite) is True - - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["favourite"] is True - view.toggleFavourite("picked:test") - assert cast("dict[str, Any]", view.detail)["favourite"] is False - await view.back() - assert view.tab == "Favourites" and model.count == 0 - assert not warnings, [warning.toString() for warning in warnings] - - -async def test_back_preserves_scroll_position_and_active_filters( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - many = [ - msgspec.structs.replace(catalog.mod_item, id=str(number)) - for number in range(40) - ] - - async def long_listing(query: CatalogQuery) -> CatalogPage[CatalogMod]: - return CatalogPage(many, len(many), None) - - catalog.mods = long_listing # type: ignore[method-assign] - view.window().resize(800, 600) - view.window().show() - await view.load() - await asyncio.sleep(0.2) - view._qml.grab() - await view.filter("test search", "1.5", "all", "popular", "Utility") - await asyncio.sleep(0.2) - view._qml.grab() - assert view.searchQuery == "test search" - - root = view._qml.rootObject() - assert root is not None - listing_scroll = root.findChild(QObject, "listingScroll") - assert listing_scroll is not None - listing_scroll.setProperty("width", 800) - listing_scroll.setProperty("height", 600) - listing_scroll.setProperty("contentY", 184) - view._qml.grab() - assert listing_scroll.property("contentY") == 184 - - await view.open_item("1", "mod") - assert view.hasDetail - - await view.back() - await asyncio.sleep(0.2) - view._qml.grab() - assert not view.hasDetail - assert view.tab == "Mods" - assert view.searchQuery == "test search" - assert listing_scroll.property("contentY") == 184 - - -async def test_downloaded_collection_activates_all_or_only_its_pack( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - tmp_path: Path, -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - steamcmd, _, dependency = installed_mods - outside = AboutXmlMod( - name="Outside", - package_id=CaseInsensitiveStr("test.outside"), - provider_id="steam", - _mod_path=tmp_path / "steam" / "9", - ) - local = AboutXmlMod( - name="Local", - package_id=CaseInsensitiveStr("test.local"), - provider_id="local", - _mod_path=tmp_path / "local" / "Mine", - ) - view._ctx.load({**installed_mods, outside.uuid: outside, local.uuid: local}, []) - view._ctx.set_active([outside.uuid, local.uuid]) - await view.open_item("picked:test", "collection") - detail = cast("dict[str, Any]", view.detail) - assert detail["state"] == "installed" and detail["active"] is False - - await view.toggleCollection() - assert set(view._ctx.active_uuids) == { - steamcmd, - dependency, - outside.uuid, - local.uuid, - } - assert cast("dict[str, Any]", view.detail)["active"] is True - - await view.toggleCollection() - assert set(view._ctx.active_uuids) == {outside.uuid, local.uuid} - - await view.activateOnlyCollection() - assert set(view._ctx.active_uuids) == {steamcmd, dependency, local.uuid} - - -async def test_collection_deactivation_removes_every_active_copy( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - view._ctx.load(installed_mods, list(installed_mods)) - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["active"] is True - - await view.toggleCollection() - - assert view._ctx.active_uuids == [] - assert cast("dict[str, Any]", view.detail)["active"] is False - - -async def test_activate_only_collection_keeps_dependents_of_retained_duplicate( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - from PySide6.QtWidgets import QMessageBox - - view, catalog, _ = panel - catalog.collection_complete = True - steamcmd, steam, dependent = installed_mods - mod = cast("AboutXmlMod", installed_mods[dependent]) - mod.about_rules = BaseRules( - dependencies={ - CaseInsensitiveStr("test.mod1"): DependencyMod( - name="Dependency", package_id=CaseInsensitiveStr("test.mod1") - ) - } - ) - view._ctx.load(installed_mods, [steamcmd, steam, dependent]) - view._ctx.diagnostics_service.rebuild() - prompts: list[object] = [] - - async def accept_dependents(*args: object) -> tuple[int, None]: - prompts.append(args) - return QMessageBox.StandardButton.Yes, None - - monkeypatch.setattr( - "pxmodrim.ui.components.mod_activation.await_dialog", accept_dependents - ) - await view.open_item("picked:test", "collection") - await view.activateOnlyCollection() - - assert not prompts - assert set(view._ctx.active_uuids) == {steamcmd, dependent} - assert cast("dict[str, Any]", view.detail)["active"] is True - - -async def test_partly_downloaded_collection_has_no_activation( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], -) -> None: - view, catalog, _ = panel - catalog.collection_complete = True - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["state"] == "missing" - await view.toggleCollection() - await view.activateOnlyCollection() - assert view._ctx.active_uuids == [] - - -async def test_incomplete_collection_cannot_activate_only_its_available_members( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - view._ctx.load(installed_mods, list(installed_mods)) - active_before = view._ctx.active_uuids - - async def incomplete_plan( - mod_ids: list[str], collection_ids: list[str] - ) -> DownloadPlan: - return DownloadPlan([], ["1"], ["2"], [], {}, False) - - monkeypatch.setattr(catalog, "plan", incomplete_plan) - await view.open_item("picked:test", "collection") - assert cast("dict[str, Any]", view.detail)["state"] == "missing" - await view.toggleCollection() - await view.activateOnlyCollection() - assert view._ctx.active_uuids == active_before - - -async def test_installed_changes_during_fetch_are_loaded_after_it_finishes( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - await view.load() - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - started, release = asyncio.Event(), asyncio.Event() - requested: list[list[str]] = [] - - async def installed() -> list[CatalogMod]: - ids = list(catalog._installed()) - requested.append(ids) - if len(requested) == 1: - started.set() - await release.wait() - items = {"1": catalog.mod_item, "2": catalog.dependency} - return [items[pid] for pid in ids] - - monkeypatch.setattr(catalog, "installed_with_updates", installed) - view._tab = "Installed" - task = asyncio.create_task(view.load()) - async with asyncio.timeout(3): - await started.wait() - view._ctx.load(installed_mods, []) - catalog._installed_index = None - await view._on_installed_changed(None) - release.set() - await task - assert requested == [["1"], ["1", "2"]] - assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} - - -async def test_installed_changes_preserve_details_and_reload_when_going_back( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - installed_mods: dict[str, ListedMod], - monkeypatch: pytest.MonkeyPatch, -) -> None: - view, catalog, _ = panel - catalog._ctx = view._ctx - steamcmd, _, _ = installed_mods - view._ctx.load({steamcmd: installed_mods[steamcmd]}, []) - - async def installed() -> list[CatalogMod]: - items = {"1": catalog.mod_item, "2": catalog.dependency} - return [items[pid] for pid in catalog._installed()] - - monkeypatch.setattr(catalog, "installed_with_updates", installed) - await view.select_tab("Installed") - await view.open_item("1", "mod") - view._ctx.load(installed_mods, []) - catalog._installed_index = None - await view._on_installed_changed(None) - assert view.hasDetail and cast("dict[str, Any]", view.detail)["itemId"] == "1" - await view.back() - assert not view.hasDetail - assert {row["itemId"] for row in view.mods_model._rows} == {"1", "2"} - - -@pytest.mark.parametrize("failure", [False, True]) -async def test_download_plan_finishing_after_destruction_is_discarded( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, - failure: bool, -) -> None: - view, catalog, _ = panel - await view.load() - started, release = asyncio.Event(), asyncio.Event() - plan = catalog.plan - - async def delayed_plan( - mod_ids: list[str], collection_ids: list[str] - ) -> DownloadPlan: - started.set() - await release.wait() - if failure: - raise CatalogError("Catalog unavailable", 503) - return await plan(mod_ids, collection_ids) - - monkeypatch.setattr(catalog, "plan", delayed_plan) - task = asyncio.create_task(view._download(["1"], [])) - async with asyncio.timeout(3): - await started.wait() - view.teardown() - view._qml.setSource(QUrl()) - delete(view) - release.set() - await task - assert not catalog.downloaded - - -async def test_revisiting_installed_within_freshness_does_not_refetch( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], -) -> None: - view, catalog, _ = panel - await asyncio.sleep(0) - fetches = 0 - mods = [catalog.mod_item] - - async def installed() -> list[CatalogMod]: - nonlocal fetches - fetches += 1 - return mods - - catalog.installed_with_updates = installed # type: ignore[method-assign] - for tab in ("Installed", "Mods", "Installed"): - await view.select_tab(tab) - assert fetches == 1 and view.mods_model.count == 1 - - -@pytest.mark.parametrize( - ("control", "tooltip_binding"), - [("PxButton", "ToolTip.text"), ("PxBadge", "tooltip")], -) -def test_card_controls_show_tooltips_lazily_on_hover( - qapp: object, - qtbot: QtBot, - tmp_path: Path, - control: str, - tooltip_binding: str, -) -> None: - owner = QWidget() - owner.resize(300, 160) - engine = create_qml_engine(owner) - theme = Theme(engine) - engine.rootContext().setContextProperty("Theme", theme) - controls = QUrl.fromLocalFile( - str(Path(__file__).parents[2] / "src/pxmodrim/ui/components/controls") - ).toString() - source = tmp_path / "Tooltips.qml" - source.write_text( - f'import QtQuick\nimport QtQuick.Controls\nimport "{controls}"\n' - "Rectangle {\n" - ' property string tipText: ""\n' - f" {control} {{\n" - ' objectName: "target"; x: 20; y: 20; text: "Test action"\n' - f" {tooltip_binding}: parent.tipText\n" - " }\n" - "}\n" - ) - widget = QQuickWidget(engine, owner) - widget.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) - widget.resize(300, 160) - widget.setSource(QUrl.fromLocalFile(str(source))) - owner.show() - widget.show() - root = widget.rootObject() - assert root is not None - target = root.findChild(QObject, "target") - assert target is not None - - def tooltip() -> QObject | None: - return next( - ( - obj - for obj in root.findChildren(QObject) - if obj.inherits("QQuickToolTip") - ), - None, - ) - - try: - qtbot.mouseMove(widget, QPoint(280, 140)) - assert tooltip() is None - root.setProperty("tipText", "Help for this action") - assert tooltip() is None - qtbot.mouseMove(widget, QPoint(30, 25)) - qtbot.waitUntil( - lambda: (tip := tooltip()) is not None and bool(tip.property("opened")), - timeout=3000, - ) - tip = tooltip() - assert tip is not None - assert tip.property("text") == "Help for this action" - assert tip.property("delay") == theme.tooltipDelay - assert tip.property("parent") == target - accessible = QAccessible.queryAccessibleInterface(target) - assert accessible is not None - assert accessible.text(QAccessible.Text.Name) == "Test action" - qtbot.mouseMove(widget, QPoint(280, 140)) - qtbot.waitUntil(lambda: tooltip() is None) - qtbot.mouseMove(widget, QPoint(30, 25)) - qtbot.waitUntil( - lambda: (tip := tooltip()) is not None and bool(tip.property("opened")), - timeout=3000, - ) - root.setProperty("tipText", "") - qtbot.waitUntil(lambda: tooltip() is None) - finally: - widget.setSource(QUrl()) - owner.close() - owner.deleteLater() - - -@pytest.mark.parametrize("kind", ["collection", "mod"]) -@pytest.mark.parametrize("long_description", [False, True]) -async def test_detail_section_layout_and_jump_navigation( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - monkeypatch: pytest.MonkeyPatch, - kind: str, - long_description: bool, -) -> None: - view, catalog, warnings = panel - members: list[CatalogMod | CatalogCollection] = [ - _mod(str(index)) for index in range(2, 14) - ] - description = "\n\n".join( - f"Paragraph {index}: a detailed explanation of this Workshop item." - for index in range(70 if long_description else 1) - ) - collection = msgspec.structs.replace( - catalog.collection_item, - description=description, - member_ids=[member.id for member in members], - member_count=len(members), - ) - - async def collection_detail(_: str) -> CollectionDetail: - return CollectionDetail(collection, members, [], True) - - catalog.mod_item = msgspec.structs.replace( - catalog.mod_item, - description=description, - description_format="text", - dependencies=[member.id for member in members], - ) - - async def mod_detail(item_id: str) -> CatalogMod | None: - return next( - (mod for mod in [catalog.mod_item, *members] if mod.id == item_id), None - ) - - monkeypatch.setattr(catalog, "collection", collection_detail) - monkeypatch.setattr(catalog, "mod", mod_detail) - view.setParent(None) - view.resize(1300, 1000) - view.show() - try: - await asyncio.sleep(0) - await view.load() - await view.open_item(collection.id if kind == "collection" else "1", kind) - await asyncio.sleep(0.05) - view._qml.grabFramebuffer() - root = cast("QQuickItem", view._qml.rootObject()) - desc = _find_item(root, "detailDescription") - members_section = _find_item(root, "detailMembers") - heading = _find_item(root, "membersHeading") - grid = _find_item(root, "membersGrid") - scroll = _find_item(root, "detailScroll") - jump = _find_item(root, "jumpToMembers") - back = _find_item(root, "detailBackToTop") - assert all( - item is not None - for item in (desc, members_section, heading, grid, scroll, jump, back) - ) - assert desc is not None and members_section is not None - assert heading is not None and scroll is not None and grid is not None - assert jump is not None and back is not None - if kind == "collection": - assert members_section.y() >= desc.y() + desc.height() - assert grid.property("columns") == 3 - else: - assert members_section.y() == pytest.approx(desc.y()) - assert members_section.x() >= desc.x() + desc.width() - assert grid.property("columns") == 1 - assert jump.isVisible() is long_description - assert not back.isVisible() - flickable = cast("QQuickItem", scroll.property("contentItem")) - if long_description: - content = cast("QQuickItem", flickable.property("contentItem")) - target = heading.mapToItem(content, heading.boundingRect().topLeft()).y() - maximum = max(0, flickable.property("contentHeight") - flickable.height()) - QTest.mouseClick( - view._qml, - Qt.MouseButton.LeftButton, - pos=jump.mapToScene(jump.boundingRect().center()).toPoint(), - ) - await asyncio.sleep(0.05) - view._qml.grabFramebuffer() - assert flickable.property("contentY") == pytest.approx( - max(0, min(target, maximum)) - ) - assert flickable.property("contentY") > 0 - assert back.isVisible() - QTest.mouseClick( - view._qml, - Qt.MouseButton.LeftButton, - pos=back.mapToScene(back.boundingRect().center()).toPoint(), - ) - await asyncio.sleep(0.05) - assert flickable.property("contentY") == 0 - assert not back.isVisible() - assert not warnings, [warning.toString() for warning in warnings] - finally: - view.close() - - -@pytest.mark.parametrize( - ("tags", "versions", "visible"), - [ - (["Mod"], ["1.6"], True), - ([], ["1.6"], True), - (["Mod"], [], True), - ([], [], False), - ], -) -async def test_detail_metadata_preserves_tags_and_versions( - panel: tuple[WorkshopViewPanel, FakeCatalog, list[Any]], - tags: list[str], - versions: list[str], - visible: bool, -) -> None: - view, catalog, warnings = panel - catalog.collection_item = msgspec.structs.replace( - catalog.collection_item, tags=tags, supported_versions=versions - ) - await asyncio.sleep(0) - await view.load() - await view.open_item(catalog.collection_item.id, "collection") - root = cast("QQuickItem", view._qml.rootObject()) - metadata = _find_item(root, "detailTags") - assert metadata is not None - assert metadata.property("visible") is visible - displayed = metadata.property("text") - for value in tags + versions: - assert value in displayed - assert not warnings, [warning.toString() for warning in warnings] diff --git a/tests/test_workshop/conftest.py b/tests/test_workshop/conftest.py deleted file mode 100644 index 525171e..0000000 --- a/tests/test_workshop/conftest.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import pytest - - -@pytest.fixture -def mod_payload() -> dict[str, Any]: - return { - "id": "2009463077", - "kind": "mod", - "source": "steam", - "title": "Harmony", - "author": { - "id": "76561198000000000", - "name": "Author", - "profile_url": "https://steamcommunity.com/profiles/76561198000000000", - }, - "description": "[b]Untrusted[/b] ", - "description_format": "bbcode", - "preview_url": "https://images.example/harmony.jpg", - "previews": [ - {"type": "image", "url": "https://images.example/harmony.jpg"}, - {"type": "video", "url": "https://www.youtube.com/watch?v=example"}, - ], - "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=2009463077", - "tags": ["1.6", "Libraries"], - "supported_versions": ["1.5", "1.6"], - "created_at": 1600000000, - "updated_at": 1700000000, - "file_size": "18446744073709551615", - "subscriptions": 12345, - "votes": {"up": 9, "down": 1, "positive_percent": 90}, - "dependencies": ["818773962"], - "package_id": "brrainz.harmony", - "incompatible": False, - } - - -@pytest.fixture -def collection_payload() -> dict[str, Any]: - return { - "id": "picked:starter", - "kind": "collection", - "source": "picked", - "steam_id": None, - "title": "Starter pack", - "author": {"id": None, "name": "PxModRim", "profile_url": None}, - "description": "Plain text", - "description_format": "text", - "preview_url": None, - "previews": [], - "workshop_url": None, - "tags": ["Quality of Life"], - "supported_versions": ["1.6"], - "created_at": 1600000000, - "updated_at": 1700000000, - "member_ids": ["2009463077"], - "member_count": 1, - "member_previews": [], - "total_size": None, - "no_common_version": False, - } diff --git a/tests/test_workshop/test_catalog.py b/tests/test_workshop/test_catalog.py deleted file mode 100644 index e28d4f6..0000000 --- a/tests/test_workshop/test_catalog.py +++ /dev/null @@ -1,405 +0,0 @@ -from __future__ import annotations - -import asyncio -from pathlib import Path -from typing import Any - -import httpx -import msgspec -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.context import CoreContext -from pxmodrim.core.downloads.manager import download_manager -from pxmodrim.core.downloads.steam import SteamDownloader, WorkshopSyncState -from pxmodrim.core.downloads.types import DownloadResult -from pxmodrim.core.mod_service import ModService -from pxmodrim.core.models.metadata.structures import ListedMod -from pxmodrim.core.workshop import ( - CatalogClient, - CatalogError, - CatalogMod, - CatalogSettings, - DownloadPlan, - WorkshopCatalog, -) - - -def make_context(tmp_path: Path) -> tuple[CoreContext, SteamDownloader]: - config = AppConfig() - config.paths.local = str(tmp_path / "Mods") - config.paths.workshop = str(tmp_path / "Workshop") - ctx = CoreContext(config, ConfigService(tmp_path)) - ctx._mod_service = ModService(ctx, []) - downloader = SteamDownloader() - downloader.setup(ctx) - downloader._sync = WorkshopSyncState(synced={"1": 200, "2": 50}) - mods = [ - ListedMod(_mod_path=tmp_path / "Mods" / "1"), - ListedMod(_mod_path=tmp_path / "Mods" / "2"), - ListedMod(_mod_path=tmp_path / "Workshop" / "3", provider_id="workshop"), - ListedMod(_mod_path=tmp_path / "Mods" / "4"), - ListedMod(_mod_path=tmp_path / "Mods" / "NotWorkshop"), - ] - ctx.load({mod.uuid: mod for mod in mods}, [mods[0].uuid]) - return ctx, downloader - - -def decode_mod( - payload: dict[str, Any], id: str, updated: int | None = 100 -) -> CatalogMod: - return msgspec.json.decode( - msgspec.json.encode({**payload, "id": id, "updated_at": updated}), - type=CatalogMod, - ) - - -async def test_install_state_uses_sync_not_directory_mtime( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - assert catalog.install_state(decode_mod(mod_payload, "1")) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "2")) == "outdated" - assert catalog.install_state(decode_mod(mod_payload, "3")) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "4")) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "5")) == "missing" - assert catalog.install_state(decode_mod(mod_payload, "2", None)) == "installed" - assert catalog.install_state(decode_mod(mod_payload, "2", 50)) == "installed" - await catalog.shutdown() - - -async def test_plan_keeps_dependency_order_and_incomplete_flags( - tmp_path: Path, mod_payload: dict[str, Any], collection_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - active_before = ctx.active_uuids - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response( - 200, - json={ - "roots": ["5", "picked:starter"], - "items": { - **{ - id: { - **mod_payload, - "id": id, - "updated_at": 100, - "title": f"Title {id}", - } - for id in ["1", "2", "3", "4", "5"] - }, - "picked:starter": collection_payload, - }, - "mod_ids": ["1", "2", "3", "4", "5"], - "unavailable_ids": ["99"], - "incomplete_collection_ids": ["picked:starter"], - "is_complete": False, - }, - ) - - catalog = WorkshopCatalog( - lambda url: CatalogClient(url, httpx.MockTransport(handler)) - ) - catalog.setup(ctx) - try: - plan = await catalog.plan(["5"], ["picked:starter"]) - assert plan.to_download == ["2", "5"] - assert plan.already_current == ["1", "3", "4"] - assert plan.unavailable_ids == ["99"] - assert plan.incomplete_collection_ids == ["picked:starter"] - assert not plan.is_complete - assert plan.titles["2"] == "Title 2" - assert ctx.active_uuids == active_before - assert msgspec.json.decode(requests[0].content) == { - "ids": ["5"], - "collection_ids": ["picked:starter"], - } - finally: - await catalog.shutdown() - - -async def test_installed_metadata_uses_batch( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - requested: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requested.extend( - msgspec.json.decode(request.content, type=dict[str, list[str]])["ids"] - ) - return httpx.Response( - 200, - json={ - "items": [{**mod_payload, "id": "2", "updated_at": 100}], - "unavailable_ids": ["1", "3", "4"], - }, - ) - - catalog = WorkshopCatalog( - lambda url: CatalogClient(url, httpx.MockTransport(handler)) - ) - catalog.setup(ctx) - try: - installed = await catalog.installed_with_updates() - assert requested == ["1", "2", "3", "4"] - assert catalog.install_state(installed[0]) == "outdated" - finally: - await catalog.shutdown() - - -async def test_download_delegates_without_activation( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - calls: list[list[str]] = [] - result = DownloadResult(succeeded=["2"], failed=[]) - - async def download(ids: list[str]) -> DownloadResult: - calls.append(ids) - return result - - monkeypatch.setattr(download_manager(ctx), "download_mods", download) - active_before = ctx.active_uuids - try: - plan = DownloadPlan(["2"], ["1"], [], [], {"2": "Mod"}, True) - assert await catalog.download(plan) is result - assert calls == [["2"]] - assert ctx.active_uuids == active_before - finally: - await catalog.shutdown() - - -async def test_empty_url_disabled_without_constructing_client(tmp_path: Path) -> None: - ctx, _ = make_context(tmp_path) - clients: list[str] = [] - - def factory(url: str) -> CatalogClient: - clients.append(url) - raise AssertionError("Disabled catalog must not create a client") - - catalog = WorkshopCatalog(factory) - catalog.setup(ctx) - catalog.set_base_url("") - try: - assert not catalog.configured - for operation in [ - catalog.discover(), - catalog.installed_with_updates(), - catalog.plan(["1"], []), - ]: - with pytest.raises(CatalogError, match="disabled"): - await operation - assert clients == [] - finally: - await catalog.shutdown() - - -async def test_configuration_and_installed_events( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - transports: list[httpx.AsyncClient] = [] - urls: list[str] = [] - - def factory(url: str) -> CatalogClient: - urls.append(url) - client = CatalogClient( - url, httpx.MockTransport(lambda _: httpx.Response(200, json=mod_payload)) - ) - transports.append(client._http) - return client - - catalog = WorkshopCatalog(factory) - catalog.setup(ctx) - changed: list[str] = [] - installed: list[None] = [] - catalog.catalog_url_changed.connect(changed.append) - catalog.installed_changed.connect(installed.append) - await catalog.mod("2009463077") - catalog.set_base_url(" https://other.example/ ") - assert catalog.settings.value.url == "https://other.example" - stored = ConfigService(tmp_path).load_existing( - "plugins/workshop_catalog.json", CatalogSettings - ) - assert stored == CatalogSettings("https://other.example") - await catalog.mod("2009463077") - catalog.set_base_url("") - assert not catalog.configured - with pytest.raises(CatalogError, match="disabled"): - await catalog.discover() - ctx.mod_service.mods_changed.emit(None) - download_manager(ctx).download_finished.emit(DownloadResult([], [])) - assert installed == [None, None] - assert changed == ["https://other.example", ""] - assert urls == ["https://api.modrim.pyxiion.dev", "https://other.example"] - await catalog.shutdown() - assert all(transport.is_closed for transport in transports) - ctx.mod_service.mods_changed.emit(None) - assert installed == [None, None] - - -async def test_cached_serves_remembered_value_before_refreshing( - tmp_path: Path, -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - values = iter(["first", "second", "second"]) - calls = 0 - - async def produce() -> str: - nonlocal calls - calls += 1 - return next(values) - - async def seen(fresh_for: float) -> list[str]: - return [value async for value in catalog.cached("k", produce, fresh_for)] - - try: - assert await seen(300) == ["first"] - assert await seen(300) == ["first"] and calls == 1 - assert await seen(0) == ["first", "second"] and calls == 2 - assert await seen(0) == ["second"] and calls == 3 - finally: - await catalog.shutdown() - - -async def test_enqueue_runs_batches_in_order_after_the_running_download( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - ctx, downloader = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - manager = download_manager(ctx) - manager.register(downloader) - calls: list[list[str]] = [] - busy = True - - async def download(ids: list[str]) -> DownloadResult: - calls.append(ids) - return DownloadResult(succeeded=ids, failed=[]) - - monkeypatch.setattr(manager, "download_mods", download) - monkeypatch.setattr(type(manager), "is_downloading", property(lambda _self: busy)) - try: - catalog.enqueue(DownloadPlan(["5", "6"], [], [], [], {}, True)) - catalog.enqueue(DownloadPlan(["6", "7"], [], [], [], {}, True)) - assert catalog.queued_ids == {"5", "6", "7"} - await asyncio.sleep(0.05) - assert calls == [] - busy = False - manager.busy_changed.emit(False) - await asyncio.wait_for(catalog._runner, 2) # type: ignore[arg-type] - assert calls == [["5", "6"], ["7"]] and not catalog.queued_ids - finally: - await catalog.shutdown() - - -async def test_cached_values_survive_a_restart_and_corrupt_files_are_ignored( - tmp_path: Path, -) -> None: - ctx, _ = make_context(tmp_path) - - async def produce() -> list[int]: - return [1, 2] - - async def must_not_run() -> list[int]: - raise AssertionError("A fresh value on disk must not hit the catalog") - - first = WorkshopCatalog() - first.setup(ctx) - assert [v async for v in first.cached("k", produce, value_type=list[int])] == [ - [1, 2] - ] - - second = WorkshopCatalog() - second.setup(ctx) - assert [ - v async for v in second.cached("k", must_not_run, value_type=list[int]) - ] == [[1, 2]] - - for file in (tmp_path / "workshop-cache").glob("*.json"): - file.write_text("{not json") - third = WorkshopCatalog() - third.setup(ctx) - assert [v async for v in third.cached("k", produce, value_type=list[int])] == [ - [1, 2] - ] - - -async def test_equal_cache_refresh_keeps_the_displayed_mods_and_refreshes_age( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - catalog = WorkshopCatalog() - catalog.setup(ctx) - calls = 0 - - async def produce() -> list[CatalogMod]: - nonlocal calls - calls += 1 - return [decode_mod(mod_payload, "1")] - - try: - initial = await anext(catalog.cached("installed", produce)) - refreshed = [ - value async for value in catalog.cached("installed", produce, fresh_for=0) - ] - assert len(refreshed) == 1 and refreshed[0] is initial - current = await anext(catalog.cached("installed", produce)) - assert current is initial and calls == 2 - finally: - await catalog.shutdown() - - -async def test_disk_cache_is_reused_in_memory_after_first_paint( - tmp_path: Path, mod_payload: dict[str, Any] -) -> None: - ctx, _ = make_context(tmp_path) - - async def produce() -> list[CatalogMod]: - return [decode_mod(mod_payload, "1")] - - async def must_not_run() -> list[CatalogMod]: - raise AssertionError("A fresh cached response must not hit the catalog") - - first = WorkshopCatalog() - first.setup(ctx) - second = WorkshopCatalog() - second.setup(ctx) - try: - await anext(first.cached("installed", produce, value_type=list[CatalogMod])) - displayed = await anext( - second.cached("installed", must_not_run, value_type=list[CatalogMod]) - ) - for file in (tmp_path / "workshop-cache").glob("*.json"): - file.write_text("{not json") - current = await anext( - second.cached("installed", must_not_run, value_type=list[CatalogMod]) - ) - assert current is displayed - finally: - await first.shutdown() - await second.shutdown() - - -def test_catalog_url_moves_out_of_the_old_shared_config(tmp_path: Path) -> None: - ctx, _ = make_context(tmp_path) - (tmp_path / "config.json").write_bytes( - b'{"schema_version":1,"workshop_catalog_url":"http://localhost:8787"}' - ) - catalog = WorkshopCatalog() - catalog.setup(ctx) - assert catalog.settings.value.url == "http://localhost:8787" - stored = ConfigService(tmp_path).load_existing( - "plugins/workshop_catalog.json", CatalogSettings - ) - assert stored == CatalogSettings("http://localhost:8787") diff --git a/tests/test_workshop/test_client.py b/tests/test_workshop/test_client.py deleted file mode 100644 index dca6154..0000000 --- a/tests/test_workshop/test_client.py +++ /dev/null @@ -1,387 +0,0 @@ -from __future__ import annotations - -from typing import Any - -import httpx -import msgspec -import pytest - -from pxmodrim.core.workshop import ( - CatalogClient, - CatalogCollection, - CatalogError, - CatalogMod, - CatalogQuery, - WorkshopCatalog, -) -from pxmodrim.ui.plugins.workshop.details import fetch_detail - - -async def test_decodes_every_endpoint( - mod_payload: dict[str, Any], collection_payload: dict[str, Any] -) -> None: - mod_page = {"items": [mod_payload], "total": 1, "next_cursor": "next-page"} - collection_page = {"items": [collection_payload], "total": 1, "next_cursor": None} - detail = { - "collection": collection_payload, - "members": [mod_payload], - "unavailable_ids": ["99"], - "is_complete": False, - } - steam_collection = { - **collection_payload, - "id": "steam:123", - "source": "steam", - "steam_id": "123", - "description_format": "bbcode", - "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", - "member_ids": ["2009463077", "456", "99"], - "member_count": 3, - "member_previews": ["https://images.example.test/a.png"], - } - nested_collection = { - **steam_collection, - "id": "steam:456", - "steam_id": "456", - "member_ids": ["2009463077"], - "member_count": 1, - "member_previews": [], - } - steam_detail = { - **detail, - "collection": steam_collection, - "members": [mod_payload, nested_collection], - } - resolution = { - "roots": ["picked:starter"], - "items": {"2009463077": mod_payload, "picked:starter": collection_payload}, - "mod_ids": ["2009463077"], - "unavailable_ids": ["99"], - "incomplete_collection_ids": ["picked:starter"], - "is_complete": False, - } - responses = { - "/catalog/discover": {"mods": mod_page, "collections": collection_page}, - "/catalog/mods": mod_page, - "/catalog/collections": collection_page, - "/catalog/mods/2009463077": mod_payload, - "/catalog/collections/picked/starter": detail, - "/catalog/collections/steam/123": steam_detail, - "/catalog/resolve": resolution, - } - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(200, json=responses[request.url.path]) - - client = CatalogClient("https://catalog.example/", httpx.MockTransport(handler)) - try: - discover = await client.discover() - assert discover.mods.items[0].file_size == "18446744073709551615" - assert discover.mods.items[0].votes is not None - assert discover.mods.items[0].votes.positive_percent == 90 - assert discover.mods.items[0].previews[1].type == "video" - assert discover.collections.items[0].description_format == "text" - q = CatalogQuery("quality", "Libraries", "1.6", "updated", 50, "next", "picked") - page = await client.mods(q) - assert page.next_cursor == "next-page" - assert dict(requests[-1].url.params) == { - "q": "quality", - "tag": "Libraries", - "version": "1.6", - "sort": "updated", - "limit": "50", - "cursor": "next", - "source": "picked", - } - assert (await client.collections(q)).items[0].id == "picked:starter" - mod = await client.mod("2009463077") - assert mod is not None and mod.description == mod_payload["description"] - assert mod.kind == "mod" - for id in ["picked:starter", "steam:123"]: - collection = await client.collection(id) - assert collection is not None - assert isinstance(collection.members[0], CatalogMod) - if id == "steam:123": - assert isinstance(collection.members[1], CatalogCollection) - assert collection.unavailable_ids == ["99"] - assert not collection.is_complete - resolved = await client.resolve([], ["picked:starter"]) - assert resolved.roots == ["picked:starter"] - assert resolved.mod_ids == ["2009463077"] - assert isinstance(resolved.items["picked:starter"], CatalogCollection) - assert resolved.incomplete_collection_ids == ["picked:starter"] - assert not resolved.is_complete - assert msgspec.json.decode(requests[-1].content) == { - "ids": [], - "collection_ids": ["picked:starter"], - } - finally: - await client.shutdown() - - -@pytest.mark.parametrize("status", [400, 401, 500, 502, 503]) -@pytest.mark.parametrize("json_body", [True, False]) -async def test_error_statuses_are_never_empty_results( - status: int, json_body: bool -) -> None: - def handler(_: httpx.Request) -> httpx.Response: - if json_body: - return httpx.Response( - status, json={"error": {"message": "Catalog unavailable"}} - ) - return httpx.Response(status, text="Upstream failure") - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError) as exc: - await client.mod("123") - assert exc.value.status == status - assert exc.value.message == str(exc.value) - if json_body: - assert exc.value.message == "Catalog unavailable" - else: - assert f"HTTP {status}" in exc.value.message - assert "" not in exc.value.message - finally: - await client.shutdown() - - -@pytest.mark.parametrize("kind", ["mod", "collection"]) -@pytest.mark.parametrize("json_body", [True, False]) -async def test_optional_details_return_none_only_for_404( - kind: str, json_body: bool -) -> None: - def handler(_: httpx.Request) -> httpx.Response: - if json_body: - return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) - return httpx.Response(404, text="Not found") - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - if kind == "mod": - assert await client.mod("123") is None - else: - assert await client.collection("steam:123") is None - finally: - await client.shutdown() - - -@pytest.mark.parametrize("kind", ["mod", "collection"]) -async def test_optional_details_keep_server_errors(kind: str) -> None: - client = CatalogClient( - "https://catalog.example", - httpx.MockTransport(lambda _: httpx.Response(500, text="Server error")), - ) - try: - with pytest.raises(CatalogError) as exc: - if kind == "mod": - await client.mod("123") - else: - await client.collection("steam:123") - assert exc.value.status == 500 - finally: - await client.shutdown() - - -@pytest.mark.parametrize("kind", ["mod", "collection"]) -@pytest.mark.parametrize("failure", ["network", "decoding"]) -async def test_optional_details_keep_network_and_decoding_errors( - kind: str, failure: str -) -> None: - def handler(request: httpx.Request) -> httpx.Response: - if failure == "network": - raise httpx.ConnectError("Disconnected", request=request) - return httpx.Response(200, content=b"not-json") - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError) as exc: - if kind == "mod": - await client.mod("123") - else: - await client.collection("steam:123") - assert exc.value.status == (None if failure == "network" else 200) - finally: - await client.shutdown() - - -@pytest.mark.parametrize("endpoint", ["mods", "collections", "batch", "resolve"]) -async def test_non_optional_endpoints_keep_404_errors(endpoint: str) -> None: - client = CatalogClient( - "https://catalog.example", - httpx.MockTransport(lambda _: httpx.Response(404)), - ) - try: - with pytest.raises(CatalogError) as exc: - if endpoint == "mods": - await client.mods(CatalogQuery()) - elif endpoint == "collections": - await client.collections(CatalogQuery()) - elif endpoint == "batch": - await client.mods_batch(["123"]) - else: - await client.resolve(["123"], []) - assert exc.value.status == 404 - finally: - await client.shutdown() - - -async def test_fetch_detail_preserves_parent_with_unavailable_dependency( - mod_payload: dict[str, Any], -) -> None: - requested: list[str] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requested.append(request.url.path) - if request.url.path == f"/catalog/mods/{mod_payload['id']}": - return httpx.Response(200, json=mod_payload) - assert request.url.path == "/catalog/mods/818773962" - return httpx.Response(404, json={"error": {"message": "Item unavailable"}}) - - catalog = WorkshopCatalog( - lambda url: CatalogClient(url, httpx.MockTransport(handler)), - ) - try: - detail = await fetch_detail(catalog, mod_payload["id"], "mod") - assert detail.item.id == mod_payload["id"] - assert detail.item.title == mod_payload["title"] - assert detail.members == [] - assert detail.warning == "Unavailable dependencies: 818773962" - assert not detail.complete - assert requested == [ - f"/catalog/mods/{mod_payload['id']}", - "/catalog/mods/818773962", - ] - finally: - await catalog.shutdown() - - -@pytest.mark.parametrize( - "invalid", ["numeric_id", "bad_kind", "missing_field", "not_json"] -) -async def test_strict_wire_validation( - mod_payload: dict[str, Any], invalid: str -) -> None: - if invalid == "numeric_id": - mod_payload["id"] = 123 - elif invalid == "bad_kind": - mod_payload["kind"] = "collection" - elif invalid == "missing_field": - del mod_payload["title"] - - def handler(_: httpx.Request) -> httpx.Response: - return ( - httpx.Response(200, content=b"not-json") - if invalid == "not_json" - else httpx.Response(200, json=mod_payload) - ) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError, match="invalid response"): - await client.mod("123") - finally: - await client.shutdown() - - -async def test_chunks_large_batch(mod_payload: dict[str, Any]) -> None: - batches: list[list[str]] = [] - - def handler(request: httpx.Request) -> httpx.Response: - ids = msgspec.json.decode(request.content, type=dict[str, list[str]])["ids"] - batches.append(ids) - return httpx.Response( - 200, - json={ - "items": [{**mod_payload, "id": id} for id in ids if id != "105"], - "unavailable_ids": ["105"] if "105" in ids else [], - }, - ) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - ids = [str(id) for id in range(1, 206)] - try: - items, unavailable = await client.mods_batch(ids) - assert [len(batch) for batch in batches] == [100, 100, 5] - assert [item.id for item in items] == [id for id in ids if id != "105"] - assert unavailable == ["105"] - assert await client.mods_batch([]) == ([], []) - finally: - await client.shutdown() - - -async def test_transport_failure_is_catalog_error() -> None: - def handler(request: httpx.Request) -> httpx.Response: - raise httpx.ConnectError("offline", request=request) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError, match="Cannot reach") as exc: - await client.discover() - assert exc.value.status is None - finally: - await client.shutdown() - - -async def test_empty_url_does_not_use_transport() -> None: - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(200) - - client = CatalogClient("", httpx.MockTransport(handler)) - try: - with pytest.raises(CatalogError, match="disabled"): - await client.discover() - assert requests == [] - finally: - await client.shutdown() - - -async def test_resolve_chunks_update_all_and_deduplicates_dependencies( - mod_payload: dict[str, Any], -) -> None: - requests: list[dict[str, list[str]]] = [] - ids = [str(id) for id in range(1, 104)] - collections = [f"picked:pack-{index}" for index in range(11)] - - def handler(request: httpx.Request) -> httpx.Response: - payload = msgspec.json.decode(request.content, type=dict[str, list[str]]) - requests.append(payload) - mod_ids = ["999", *payload["ids"]] - return httpx.Response( - 200, - json={ - "roots": [*payload["ids"], *payload["collection_ids"]], - "items": { - id: { - **mod_payload, - "id": id, - "dependencies": [] if id == "999" else ["999"], - } - for id in mod_ids - }, - "mod_ids": mod_ids, - "unavailable_ids": ["404"] if len(requests) == 2 else [], - "incomplete_collection_ids": ( - ["picked:pack-10"] if len(requests) == 2 else [] - ), - "is_complete": len(requests) != 2, - }, - ) - - client = CatalogClient("https://catalog.example", httpx.MockTransport(handler)) - try: - resolved = await client.resolve(ids, collections) - assert [len(request["ids"]) for request in requests] == [50, 50, 3] - assert [len(request["collection_ids"]) for request in requests] == [10, 1, 0] - assert set(resolved.roots) == set(ids + collections) - assert resolved.mod_ids == ["999", *ids] - assert resolved.unavailable_ids == ["404"] - assert resolved.incomplete_collection_ids == ["picked:pack-10"] - assert not resolved.is_complete - finally: - await client.shutdown() diff --git a/tests/test_workshop/test_favourites.py b/tests/test_workshop/test_favourites.py deleted file mode 100644 index 2e290c6..0000000 --- a/tests/test_workshop/test_favourites.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -import msgspec - -from pxmodrim.core.config import ConfigService -from pxmodrim.core.workshop import Author, CatalogCollection -from pxmodrim.core.workshop.favourites import FavouriteCollections - - -def _collection(cid: str, title: str = "Pack") -> CatalogCollection: - return CatalogCollection( - id=cid, - source="steam", - steam_id=cid.partition(":")[2], - title=title, - author=Author(None, "Curator", None), - description="", - description_format="text", - preview_url=None, - previews=[], - workshop_url=None, - tags=[], - supported_versions=["1.6"], - created_at=None, - updated_at=None, - member_ids=["1"], - member_count=1, - ) - - -def test_favourites_persist_newest_first_and_survive_restart( - config_service: ConfigService, -) -> None: - favourites = FavouriteCollections(config_service) - first, second = _collection("steam:1"), _collection("steam:2") - favourites.set(first, True) - favourites.set(second, True) - favourites.set(second, True) - - reloaded = FavouriteCollections(config_service) - assert [c.id for c in reloaded.items] == ["steam:2", "steam:1"] - assert "steam:1" in reloaded - - reloaded.set(first, False) - assert [c.id for c in FavouriteCollections(config_service).items] == ["steam:2"] - assert "steam:1" not in reloaded - - -def test_remember_refreshes_only_starred_snapshots( - config_service: ConfigService, -) -> None: - favourites = FavouriteCollections(config_service) - favourites.set(_collection("steam:1", "Old title"), True) - saves: list[object] = [] - favourites.changed.connect(saves.append) - - favourites.remember(_collection("steam:1", "Old title")) - favourites.remember(_collection("steam:2", "Not starred")) - assert saves == [] - - favourites.remember(_collection("steam:1", "New title")) - reloaded = FavouriteCollections(config_service) - assert [c.title for c in reloaded.items] == ["New title"] - assert ( - msgspec.json.decode( - ( - config_service.config_dir / "plugins/workshop_favourites.json" - ).read_bytes() - )["collections"][0]["id"] - == "steam:1" - ) diff --git a/tests/test_workshop/test_types.py b/tests/test_workshop/test_types.py deleted file mode 100644 index 5104d39..0000000 --- a/tests/test_workshop/test_types.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Any, cast - -import msgspec -import pytest - -from pxmodrim.core.config import AppConfig, ConfigService -from pxmodrim.core.workshop import ( - CatalogCollection, - CatalogMod, - CatalogPage, - CatalogQuery, -) - - -def test_mod_null_fields_are_not_invented(mod_payload: dict[str, Any]) -> None: - for key in [ - "preview_url", - "created_at", - "updated_at", - "file_size", - "subscriptions", - "votes", - "package_id", - ]: - mod_payload[key] = None - mod_payload["author"] = { - "id": "76561198000000000", - "name": None, - "profile_url": None, - } - mod = msgspec.json.decode(msgspec.json.encode(mod_payload), type=CatalogMod) - assert mod.author.name is None - assert mod.votes is None - assert mod.updated_at is None - assert mod.file_size is None - assert msgspec.json.decode(msgspec.json.encode(mod)) == mod_payload - with pytest.raises(AttributeError): - cast(Any, mod).title = "Changed" - - -def test_steam_collection_and_empty_page(collection_payload: dict[str, Any]) -> None: - collection_payload.update( - { - "id": "steam:123", - "source": "steam", - "steam_id": "123", - "description_format": "bbcode", - "workshop_url": "https://steamcommunity.com/sharedfiles/filedetails/?id=123", - "member_ids": ["2009463077", "steam:456"], - "member_count": 2, - "member_previews": ["https://images.example.test/a.png"], - } - ) - collection = msgspec.json.decode( - msgspec.json.encode(collection_payload), type=CatalogCollection - ) - assert collection.kind == "collection" - assert collection.member_ids == ["2009463077", "steam:456"] - assert msgspec.json.decode(msgspec.json.encode(collection)) == collection_payload - page = msgspec.json.decode( - b'{"items":[],"total":0,"next_cursor":null}', type=CatalogPage[CatalogMod] - ) - assert page.items == [] and page.total == 0 and page.next_cursor is None - - -def test_config_default_loads_existing_files_and_persists_override( - tmp_path: Path, -) -> None: - service = ConfigService(tmp_path) - (tmp_path / "config.json").write_bytes(b'{"schema_version":1,"max_snapshots":3}') - config = service.load("config.json", AppConfig) - assert config.log_upload_endpoint == "https://paste.rs/" - assert config.max_snapshots == 3 - config.log_upload_endpoint = "" - service.save("config.json", config) - assert service.load("config.json", AppConfig).log_upload_endpoint == "" - - -def test_query_defaults_and_frozen_contract() -> None: - query = CatalogQuery() - assert ( - query.query == "" - and query.sort == "popular" - and query.limit == 24 - and query.source == "all" - ) - assert query.tag is None and query.version is None and query.cursor is None - with pytest.raises(AttributeError): - cast(Any, query).limit = 50 diff --git a/tests/ui/main.cpp b/tests/ui/main.cpp new file mode 100644 index 0000000..3d32e94 --- /dev/null +++ b/tests/ui/main.cpp @@ -0,0 +1,17 @@ +#include + +#include + +#include + +Q_IMPORT_QML_PLUGIN(PxModRimPlugin) + +// One QApplication for the whole UI test binary: Qt allows a single instance +// per process. +int main(int argc, char* argv[]) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + if (qEnvironmentVariableIsEmpty("QT_QUICK_CONTROLS_STYLE")) + qputenv("QT_QUICK_CONTROLS_STYLE", "Basic"); + QApplication app(argc, argv); + return Catch::Session().run(argc, argv); +} diff --git a/tests/ui/test_dialog_chrome.cpp b/tests/ui/test_dialog_chrome.cpp new file mode 100644 index 0000000..b4101b4 --- /dev/null +++ b/tests/ui/test_dialog_chrome.cpp @@ -0,0 +1,44 @@ +#include +#include +#include +#include + +#include + +#include "ui/components/dialog_chrome.hpp" + +namespace { + +// The shared QApplication is created by tests/ui/main.cpp. +QApplication& application() { + return *static_cast(QApplication::instance()); +} + +void clickClose(QDialog& dialog) { + px::modrim::installDialogChrome(&dialog); + auto* button = + dialog.findChild(QStringLiteral("dialogCloseBtn")); + REQUIRE(button != nullptr); + button->click(); +} + +} // namespace + +TEST_CASE("message box close yields cancel") { + application(); + for (const auto buttons : + {QMessageBox::Yes | QMessageBox::No | QMessageBox::Cancel, + QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel}) { + QMessageBox box; + box.setStandardButtons(buttons); + clickClose(box); + CHECK(box.result() == static_cast(QMessageBox::Cancel)); + } +} + +TEST_CASE("plain dialog close rejects") { + application(); + QDialog dialog; + clickClose(dialog); + CHECK(dialog.result() == QDialog::Rejected); +} diff --git a/tests/ui/test_downloads_model.cpp b/tests/ui/test_downloads_model.cpp new file mode 100644 index 0000000..092656c --- /dev/null +++ b/tests/ui/test_downloads_model.cpp @@ -0,0 +1,55 @@ +#include + +#include + +#include "ui/plugins/downloads/model.hpp" + +namespace { +QHash states(const px::modrim::DownloadsModel& model) { + QHash result; + for (int row = 0; row < model.rowCount(); ++row) { + const auto index = model.index(row); + result.insert( + model.data(index, px::modrim::DownloadsModel::kIdRole).toString(), + model.data(index, px::modrim::DownloadsModel::kStateRole).toString()); + } + return result; +} +} // namespace + +TEST_CASE("download item events resolve states and cancel leftover rows") { + px::modrim::DownloadsModel model; + model.begin({"1", "2", "3", "4", "5"}, {{"1", "One"}}); + model.apply({"1", "downloading", 0, 0}); + model.apply({"2", "downloading", 5, 10}); + model.apply({"3", "downloading", 10, 10}); + model.apply({"4", "error", 0, 0, "boom"}); + model.flush(); + const QHash expected{{"1", "unchanged"}, + {"2", "downloading"}, + {"3", "updated"}, + {"4", "failed"}, + {"5", "queued"}}; + CHECK(states(model) == expected); + model.finish({{"1", "3"}, {"4"}, {"3"}}); + CHECK(states(model).value("2") == "cancelled"); + CHECK(states(model).value("5") == "cancelled"); + CHECK(model.updated() == 1); + CHECK(model.unchanged() == 1); + CHECK(model.failed() == 3); + CHECK(model.active() == 0); + CHECK(model.failedIds() == QStringList{"2", "4", "5"}); +} + +TEST_CASE("download filters limit visible rows and keep fallback titles") { + px::modrim::DownloadsModel model; + model.begin({"1", "2"}, {{"1", "One"}}); + model.apply({"2", "error", 0, 0, "x"}); + model.flush(); + model.setFilter(QStringLiteral("failed")); + REQUIRE(model.rowCount() == 1); + CHECK(model.data(model.index(0), px::modrim::DownloadsModel::kTitleRole) + .toString() == "2"); + model.setFilter(QStringLiteral("all")); + CHECK(model.rowCount() == 2); +} diff --git a/tests/ui/test_main_window.cpp b/tests/ui/test_main_window.cpp new file mode 100644 index 0000000..92a3f9d --- /dev/null +++ b/tests/ui/test_main_window.cpp @@ -0,0 +1,274 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/models/metadata/structures.hpp" +#include "ui/context.hpp" +#include "ui/window/main_window.hpp" + +namespace { +void ensureApplication() { + static int argc = 1; + static char name[] = "pxmodrim-window-tests"; + static char* argv[] = {name, nullptr}; + static std::unique_ptr app; + if (!QApplication::instance()) + app = std::make_unique(argc, argv); +} + +class FirstView : public px::modrim::RailView { + public: + using RailView::RailView; + static constexpr px::modrim::RailViewMeta kRailMeta{ + QLatin1StringView("first"), QLatin1StringView("mods"), + QLatin1StringView("First")}; +}; +class SecondView : public px::modrim::RailView { + public: + using RailView::RailView; + static constexpr px::modrim::RailViewMeta kRailMeta{ + QLatin1StringView("second"), QLatin1StringView("mods"), + QLatin1StringView("Second")}; + void openRoute(const QStringList& path) override { opened = path; } + QStringList opened; +}; +class ThirdView : public px::modrim::RailView { + public: + using RailView::RailView; + static constexpr px::modrim::RailViewMeta kRailMeta{ + QLatin1StringView("third"), QLatin1StringView("mods"), + QLatin1StringView("Third")}; +}; + +template +class InitCounter final : public px::modrim::BasicPlugin { + public: + explicit InitCounter(int& calls) + : px::modrim::BasicPlugin(QStringLiteral("init-counter")), + m_calls(calls) {} + + QCoro::Task init(Ctx&) override { + ++m_calls; + co_return; + } + + private: + int& m_calls; +}; + +struct Fixture { + QTemporaryDir directory; + px::modrim::ConfigService configService{directory.path()}; + std::unique_ptr core; + std::unique_ptr app; + explicit Fixture(bool canSave = true) { + ensureApplication(); + px::modrim::AppConfig config; + if (canSave) { + config.paths.config_folder = QDir(directory.path()) + .filePath(QStringLiteral("game-config")) + .toStdString(); + QDir().mkpath(QString::fromStdString(config.paths.config_folder)); + } + core = px::modrim::CoreContext::create(config, &configService); + core->registerPlugin(std::make_unique()); + auto a = std::make_shared(); + a->packageId = px::modrim::CaseInsensitiveStr(u"test.a"); + auto b = std::make_shared(); + b->packageId = px::modrim::CaseInsensitiveStr(u"test.b"); + core->load({{QStringLiteral("a"), a}, {QStringLiteral("b"), b}}, + {QStringLiteral("a"), QStringLiteral("b")}); + app = std::make_unique(*core); + app->addRailView(); + app->addRailView(); + app->addRailView(); + } +}; + +QStackedWidget* stackOf(px::modrim::MainWindow& window) { + return window.findChild(QStringLiteral("viewStack")); +} + +QMessageBox* closePrompt(px::modrim::MainWindow& window) { + // QMessageBox drops its window title on macOS, so match by type. + for (auto* dialog : window.findChildren()) + if (dynamic_cast(dialog) && + dialog->isVisible()) + return dialog; + return nullptr; +} +} // namespace + +TEST_CASE("View shortcuts wrap and ignore invalid indexes", "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + auto* stack = stackOf(window); + REQUIRE(stack != nullptr); + window.cycleView(-1); + CHECK(stack->currentIndex() == 2); + window.cycleView(1); + CHECK(stack->currentIndex() == 0); + window.selectView(3); + window.selectView(-1); + CHECK(stack->currentIndex() == 0); +} + +TEST_CASE("Routes select their view before handing it the path", + "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + auto* stack = stackOf(window); + REQUIRE(stack != nullptr); + window.openRoute(QStringLiteral("modrim://second/mod/42")); + CHECK(stack->currentIndex() == 1); + CHECK(static_cast(stack->widget(1))->opened == + QStringList{"mod", "42"}); + window.openRoute(QStringLiteral("modrim://missing/x")); + window.openRoute(QStringLiteral("second")); + CHECK(stack->currentIndex() == 1); +} + +TEST_CASE("Settings route leaves the selected view unchanged", "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + window.selectView(1); + window.openRoute(QStringLiteral("modrim://settings")); + CHECK(stackOf(window)->currentIndex() == 1); + bool found = false; + for (auto* dialog : window.findChildren()) { + if (dialog->isVisible()) { + found = true; + dialog->reject(); + } + } + CHECK(found); +} + +TEST_CASE("Plugin initialization runs once across configuration saves", + "[ui][window]") { + int coreCalls = 0; + int uiCalls = 0; + Fixture fixture; + fixture.core->registerPlugin( + std::make_unique>(coreCalls)); + fixture.app->registerPlugin( + std::make_unique>(uiCalls)); + REQUIRE(fixture.app->setupAll()); + REQUIRE(fixture.core->config().paths.game.empty()); + CHECK_FALSE(fixture.app->pluginsInitialized()); + + auto config = fixture.core->config(); + config.paths.game = fixture.directory.path().toStdString(); + REQUIRE(fixture.configService.save(QStringLiteral("config.json"), config)); + fixture.core->updateConfig(config); + REQUIRE(QCoro::waitFor(fixture.app->initAll())); + CHECK(fixture.app->pluginsInitialized()); + CHECK(coreCalls == 1); + CHECK(uiCalls == 1); + + config.launch_args = "-logfile second-save.log"; + REQUIRE(fixture.configService.save(QStringLiteral("config.json"), config)); + fixture.core->updateConfig(config); + REQUIRE(QCoro::waitFor(fixture.app->initAll())); + CHECK(fixture.app->pluginsInitialized()); + CHECK(coreCalls == 1); + CHECK(uiCalls == 1); +} + +TEST_CASE("Failed plugin initialization is not marked complete", + "[ui][window]") { + Fixture fixture; + fixture.app->registerPlugin(std::make_unique( + QStringLiteral("cycle_a"), QStringList{QStringLiteral("cycle_b")})); + fixture.app->registerPlugin(std::make_unique( + QStringLiteral("cycle_b"), QStringList{QStringLiteral("cycle_a")})); + + CHECK_FALSE(QCoro::waitFor(fixture.app->initAll())); + CHECK_FALSE(fixture.app->pluginsInitialized()); +} + +TEST_CASE("Dirty state tracks active list order and disk reload baseline", + "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + CHECK_FALSE(window.hasUnsavedChanges()); + fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); + CHECK(window.hasUnsavedChanges()); + CHECK(window.isWindowModified()); + fixture.core->setActive({QStringLiteral("a"), QStringLiteral("b")}); + CHECK_FALSE(window.hasUnsavedChanges()); + fixture.core->setActive({QStringLiteral("a")}); + fixture.core->modService().modsChanged.notify(std::monostate{}); + CHECK(window.hasUnsavedChanges()); +} + +TEST_CASE("Successful saves clear the unsaved state", "[ui][window]") { + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("b"), QStringLiteral("a")}); + REQUIRE(window.hasUnsavedChanges()); + CHECK(QCoro::waitFor(window.saveActiveMods())); + CHECK_FALSE(window.hasUnsavedChanges()); + CHECK_FALSE(window.isWindowModified()); +} + +TEST_CASE("Dirty close prompts once and honors Save Discard and Cancel", + "[ui][window]") { + const auto choice = + GENERATE(QMessageBox::Save, QMessageBox::Discard, QMessageBox::Cancel); + Fixture fixture; + px::modrim::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("a")}); + int closed = 0; + QEventLoop loop; + window.setAppQuitCallback([&] { + ++closed; + loop.quit(); + }); + QCloseEvent first, second; + QCoreApplication::sendEvent(&window, &first); + QCoreApplication::sendEvent(&window, &second); + CHECK_FALSE(first.isAccepted()); + CHECK_FALSE(second.isAccepted()); + auto* prompt = closePrompt(window); + REQUIRE(prompt != nullptr); + int visiblePrompts = 0; + for (auto* item : window.findChildren()) + if (item->isVisible()) ++visiblePrompts; + CHECK(visiblePrompts == 1); + QTimer::singleShot(10, prompt, [prompt, choice] { prompt->done(choice); }); + QTimer::singleShot(choice == QMessageBox::Cancel ? 40 : 3000, &loop, + &QEventLoop::quit); + loop.exec(); + CHECK(closed == (choice == QMessageBox::Cancel ? 0 : 1)); +} + +TEST_CASE("A failed close-time save leaves the window open", "[ui][window]") { + Fixture fixture(false); + px::modrim::MainWindow window(*fixture.app); + fixture.core->setActive({QStringLiteral("a")}); + bool closed = false; + window.setAppQuitCallback([&] { closed = true; }); + QCloseEvent event; + QCoreApplication::sendEvent(&window, &event); + auto* prompt = closePrompt(window); + REQUIRE(prompt != nullptr); + prompt->done(QMessageBox::Save); + CHECK_FALSE(closed); + CHECK(window.hasUnsavedChanges()); +} diff --git a/tests/ui/test_menu_bar.cpp b/tests/ui/test_menu_bar.cpp new file mode 100644 index 0000000..6d1611b --- /dev/null +++ b/tests/ui/test_menu_bar.cpp @@ -0,0 +1,74 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ui/window/actions.hpp" +#include "ui/window/menu_bar.hpp" + +namespace { +void ensureApplication() { + static int argc = 1; + static char name[] = "pxmodrim-menu-tests"; + static char* argv[] = {name, nullptr}; + static std::unique_ptr app; + if (!QApplication::instance()) + app = std::make_unique(argc, argv); +} +} // namespace + +TEST_CASE("Every window action belongs to exactly one menu", "[ui][window]") { + std::multiset placed; + for (const auto& menu : px::modrim::menuLayout()) + for (const auto id : menu.actions) + if (id) placed.insert(*id); + REQUIRE(placed.size() == px::modrim::actionSpecs().size()); + for (const auto& spec : px::modrim::actionSpecs()) + CHECK(placed.count(spec.id) == 1); +} + +TEST_CASE("Window shortcuts are unique and view switching is collapsed", + "[ui][window]") { + ensureApplication(); + std::set shortcuts; + for (const auto& spec : px::modrim::actionSpecs()) + if (!spec.shortcut.isEmpty()) CHECK(shortcuts.insert(spec.shortcut).second); + std::set> unique; + int viewRows = 0; + bool save = false, refresh = false; + for (const auto& row : px::modrim::shortcutRows()) { + CHECK(unique.emplace(row.first, row.second).second); + if (row.second == QStringLiteral("Switch to view")) ++viewRows; + save |= row.second == QStringLiteral("Save mod list"); + refresh |= row.second == QStringLiteral("Refresh mods"); + } + CHECK(viewRows == 1); + CHECK(save); + CHECK(refresh); +} + +TEST_CASE("Hidden menu actions retain their window shortcuts", "[ui][window]") { + ensureApplication(); + QMainWindow window; + const auto actions = px::modrim::createActions(window); + px::modrim::MenuBar menu(actions, &window); + menu.hide(); + bool triggered = false; + QObject::connect(actions.at(px::modrim::ActionId::kSave), &QAction::triggered, + &window, [&] { triggered = true; }); + QMenu* file = nullptr; + for (auto* item : menu.findChildren()) + if (item->title() == QStringLiteral("&File")) file = item; + REQUIRE(file != nullptr); + auto* save = actions.at(px::modrim::ActionId::kSave); + CHECK(file->actions().contains(save)); + CHECK(save->shortcut() == QKeySequence(QStringLiteral("Ctrl+S"))); + CHECK(window.actions().contains(save)); + save->trigger(); + CHECK(triggered); +} diff --git a/tests/ui/test_mod_info_data.cpp b/tests/ui/test_mod_info_data.cpp new file mode 100644 index 0000000..ea8e40a --- /dev/null +++ b/tests/ui/test_mod_info_data.cpp @@ -0,0 +1,322 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "core/downloads/manager.hpp" +#include "test_support.hpp" +#include "ui/panels/mod_info_data.hpp" + +namespace px::modrim { +namespace { + +std::shared_ptr makeMod(const QString& name, const QString& pid) { + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(pid); + mod->description.clear(); + return mod; +} + +void addDependency(AboutXmlMod& mod, const QString& pid, + const QStringList& alternatives = {}) { + DependencyMod dependency; + dependency.name = pid; + dependency.packageId = CaseInsensitiveStr(pid); + dependency.alternativePackageIds = CaseInsensitiveSet(alternatives); + mod.aboutRules.dependencies.append(std::move(dependency)); +} + +struct ModInfoContext { + QTemporaryDir game; + std::unique_ptr ctx; + + ModInfoContext(const QList& mods, QStringList active) { + if (!QCoreApplication::instance()) { + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char name[] = "pxmodrim-mod-info-tests"; + static char* argv[] = {name, nullptr}; + static QApplication application(argc, argv); + } + REQUIRE(game.isValid()); + QFile version(game.filePath("Version.txt")); + REQUIRE(version.open(QIODevice::WriteOnly)); + REQUIRE(version.write("1.5.4297") == 8); + version.close(); + AppConfig config; + config.paths.game = game.path().toStdString(); + ctx = px::ui_test::makeCore(config); + // CoreContext registers the real, empty DownloadManager during + // construction. + REQUIRE_FALSE(ctx->downloads().available()); + QHash indexed; + for (const auto& mod : mods) indexed.insert(mod->uuid(), mod); + ctx->load(std::move(indexed), std::move(active)); + } +}; + +} // namespace + +TEST_CASE("Mod info autolinks bare URLs without changing markup", + "[ui][mod-info]") { + CHECK(autolink("see https://a.com/x?y=1 now") == + "see https://a.com/x?y=1 now"); + CHECK(autolink("go to https://a.com.") == + "go to https://a.com."); + const QString anchor = "https://b.com"; + CHECK(autolink(anchor) == anchor); + CHECK( + autolink("https://a.com/?a=1&b=2") == + "https://a.com/?a=1&b=2"); +} + +TEST_CASE("Mod info converts BBCode images and linked images", + "[ui][mod-info]") { + CHECK(bbcodeImages("[img]https://i.imgur.com/a.png[/img]\nhi") == + "\nhi"); + CHECK_FALSE(autolink(bbcodeImages("[img]https://i.imgur.com/a.png[/img]")) + .contains("")); +} + +TEST_CASE("Mod info converts BBCode links and formatting", "[ui][mod-info]") { + CHECK(bbcodeMarkup("[url=https://a.com/w]the wiki[/url]") == + "the wiki"); + const QString unsafe = "[url=javascript:alert(1)]x[/url]"; + CHECK(bbcodeMarkup(unsafe) == unsafe); + CHECK(bbcodeMarkup("[b]a[/b] [i]b[/i]") == "a b"); +} + +TEST_CASE("Mod info converts Steam structural tags without stray breaks", + "[ui][mod-info]") { + CHECK(bbcodeMarkup("[list]
    [*]one
    [*]two
    [/list]
    after") == + "
    • one
    • two
    after"); + CHECK(bbcodeMarkup("[h2]Title[/h2]
    body") == "

    Title

    body"); + CHECK(bbcodeMarkup("[quote=Bob]hi[/quote]") == + "
    Bob wrote:
    hi
    "); + CHECK(bbcodeMarkup("[table noborder=1][tr][td]x[/td][/tr][/table]") == + "
    x
    "); +} + +TEST_CASE("Mod info restores entities once and excludes URL delimiters", + "[ui][mod-info]") { + CHECK(restoreEntities("&#8226; item") == "• item"); + CHECK(restoreEntities("a & b") == "a & b"); + CHECK(descriptionToHtml("see \"https://a.com/x\" ok") == + "see "https://a.com/x" ok"); + CHECK(descriptionToHtml("") == + "<https://a.com/x>"); +} + +TEST_CASE("Mod info rich descriptions preserve formatting entities and images", + "[ui][mod-info]") { + CHECK(descriptionToHtml( + "[h2]Title[/h2]\nBold •\n" + "[img]https://a.com/image.png[/img]\nhttps://a.com/wiki") == + "

    Title

    Bold •
    " + "
    " + "https://a.com/wiki"); +} + +TEST_CASE("Mod info plaintext descriptions remain literal with line breaks", + "[ui][mod-info]") { + CHECK(descriptionToHtml( + "Literal & text\n[img]https://a.com/image.png[/img]\n" + "[url=https://a.com]wiki[/url]", + "plaintext") == + "<b>Literal & text</b>
    " + "[img]https://a.com/image.png[/img]
    [url=https://a.com]wiki[/url]"); +} + +TEST_CASE("Mod info only embeds HTTPS images", "[ui][mod-info][security]") { + CHECK_FALSE( + descriptionToHtml("[img]file:///etc/passwd[/img]").contains("")); + CHECK_FALSE( + descriptionToHtml("[url=javascript:alert(1)]x[/url]").contains("x y"); + CHECK_FALSE(html.contains("") == + "<img src="file:///etc/passwd">"); +} + +TEST_CASE("Mod info rejects unsafe schemes in every rich link form", + "[ui][mod-info][security]") { + const QStringList urls{"javascript:alert(1)", "data:image/png;base64,AAAA", + "file:///etc/passwd", "qrc:/private.png", + "steam://run/294100"}; + for (const auto& url : urls) { + CAPTURE(url); + const auto html = descriptionToHtml( + "[url=" + url + "]link[/url] [url]" + url + "[/url] [img]" + url + + "[/img] Unity link"); + CHECK_FALSE(html.contains("uuid()}); + const auto needs = + buildModInfo(*context.ctx, *main, {}).value("needs").toList(); + const QVariantList expected{ + QVariantMap{ + {"name", "a.active"}, {"state", "active"}, {"uuid", active->uuid()}}, + QVariantMap{{"name", "A.Inactive"}, + {"state", "inactive"}, + {"uuid", inactive->uuid()}}, + QVariantMap{{"name", "a.missing"}, + {"state", "inactive"}, + {"uuid", alternative->uuid()}}, + QVariantMap{{"name", "a.gone"}, {"state", "missing"}, {"uuid", ""}}}; + CHECK(needs == expected); +} + +TEST_CASE("Mod info conflicts include only active mods sorted by name", + "[ui][mod-info]") { + auto beta = makeMod("beta", "x.b"); + auto alpha = makeMod("Alpha", "x.a"); + auto off = makeMod("Off", "x.off"); + auto main = makeMod("Main", "x.main"); + main->aboutRules.incompatibleWith = + CaseInsensitiveSet(QStringList{"X.B", "x.a", "x.off", "x.no"}); + ModInfoContext context({main, alpha, beta, off}, + {alpha->uuid(), beta->uuid()}); + CHECK( + buildModInfo(*context.ctx, *main, {}).value("conflicts").toStringList() == + QStringList{"Alpha", "beta"}); +} + +TEST_CASE("Mod info needed by matches alternatives and sorts dependents", + "[ui][mod-info]") { + auto target = makeMod("Target", "t.target"); + auto zed = makeMod("zed", "t.z"); + auto apple = makeMod("Apple", "t.a"); + auto unrelated = makeMod("Unrelated", "t.u"); + addDependency(*zed, "t.target"); + addDependency(*apple, "t.other", {"T.Target"}); + addDependency(*unrelated, "t.other"); + ModInfoContext context({target, zed, apple, unrelated}, {}); + const QVariantList expected{ + QVariantMap{{"name", "Apple"}, {"uuid", apple->uuid()}}, + QVariantMap{{"name", "zed"}, {"uuid", zed->uuid()}}}; + CHECK(buildModInfo(*context.ctx, *target, {}).value("neededBy").toList() == + expected); +} + +TEST_CASE( + "Mod info builds active mod status issues dependencies and description", + "[ui][mod-info]") { + auto dep = makeMod("Dep", "d.dep"); + auto main = makeMod("Main", "m.main"); + addDependency(*main, "d.dep"); + main->description = "hello x https://a.com/x"; + main->authors = {"Ann"}; + auto other = makeMod("Other", "o.other"); + ModInfoContext context({dep, main, other}, {other->uuid(), main->uuid()}); + const view::ModIssueView issue{"c", "Cat", std::nullopt, false}; + const auto info = buildModInfo(*context.ctx, *main, {issue}); + CHECK(info.value("name").toString() == "Main"); + CHECK(info.value("author").toString() == "Ann"); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "warning"); + CHECK(status.value("title").toString() == "1 warning"); + CHECK(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 2); + CHECK(status.value("total").toInt() == 2); + const QVariantList expectedIssues{ + QVariantMap{{"title", "Cat"}, {"detail", ""}, {"isError", false}}}; + CHECK(status.value("issues").toList() == expectedIssues); + const QVariantList expectedNeeds{QVariantMap{ + {"name", "d.dep"}, {"state", "inactive"}, {"uuid", dep->uuid()}}}; + CHECK(info.value("needs").toList() == expectedNeeds); + CHECK(info.value("description") + .toString() + .contains("")); + const QVariantMap expectedVersion{ + {"known", false}, {"ok", false}, {"target", "1.5"}}; + CHECK(info.value("version").toMap() == expectedVersion); +} + +TEST_CASE("Mod info builds inactive mod without description or dependents", + "[ui][mod-info]") { + auto mod = makeMod("Solo", "s.solo"); + ModInfoContext context({mod}, {}); + const auto info = buildModInfo(*context.ctx, *mod, {}); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "ok"); + CHECK_FALSE(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 0); + CHECK(info.value("description").toString().isEmpty()); + CHECK(info.value("neededBy").toList().isEmpty()); +} + +TEST_CASE("Mod info age formatting has deterministic boundaries and plurals", + "[ui][mod-info]") { + constexpr double kNow = 2'000'000'000; + CHECK(formatAge(0, kNow).isEmpty()); + CHECK(formatAge(-1, kNow).isEmpty()); + CHECK(formatAge(kNow + 1, kNow) == "just now"); + CHECK(formatAge(kNow, kNow) == "just now"); + CHECK(formatAge(kNow - 3599, kNow) == "just now"); + struct AgeCase { + double elapsed; + const char* expected; + }; + const AgeCase cases[]{{3600, "1 hour ago"}, + {7200, "2 hours ago"}, + {86399, "23 hours ago"}, + {86400, "1 day ago"}, + {2 * 86400, "2 days ago"}, + {7 * 86400 - 1, "6 days ago"}, + {7 * 86400, "1 week ago"}, + {14 * 86400, "2 weeks ago"}, + {30 * 86400 - 1, "4 weeks ago"}, + {30 * 86400, "1 month ago"}, + {60 * 86400, "2 months ago"}, + {365 * 86400 - 1, "12 months ago"}, + {365 * 86400, "1 year ago"}, + {730 * 86400, "2 years ago"}}; + for (const auto& age : cases) { + CAPTURE(age.elapsed); + CHECK(formatAge(kNow - age.elapsed, kNow) == age.expected); + } +} + +} // namespace px::modrim diff --git a/tests/ui/test_mod_list_model.cpp b/tests/ui/test_mod_list_model.cpp new file mode 100644 index 0000000..32206f1 --- /dev/null +++ b/tests/ui/test_mod_list_model.cpp @@ -0,0 +1,145 @@ +#include +#include + +#include + +#include "ui/models/impact.hpp" +#include "ui/models/mod_list_model.hpp" +#include "ui/theme/palette.hpp" +using namespace px::modrim; +namespace { +struct ListFixture { + QHash mods; + ModListModel model{{{QStringLiteral("stub"), QStringLiteral("#abcdef")}}}; + ListFixture() { + for (int i = 0; i < 5; ++i) { + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(i); + mod->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mod->providerId = QStringLiteral("stub"); + mods.insert(QStringLiteral("uuid-%1").arg(i), mod); + } + model.loadMods(mods, {"uuid-0", "uuid-2"}); + } + QStringList order() const { + QStringList result; + for (int row = 0; row < model.rowCount(); ++row) + result.append(model.getItem(row)->uuid); + return result; + } + QList indices() const { + QList result; + for (int row = 0; row < model.rowCount(); ++row) + result.append( + model.data(model.index(row), ModListModel::kLoadIndexRole).toInt()); + return result; + } +}; +} // namespace +TEST_CASE("mod list load skips missing active UUIDs and sorts inactive mods") { + ListFixture f; + CHECK(f.order() == + QStringList{"uuid-0", "uuid-2", "uuid-1", "uuid-3", "uuid-4"}); + f.model.loadMods(f.mods, {"ghost", "uuid-2"}); + CHECK(f.model.activeUuids() == QStringList{"uuid-2"}); + CHECK(f.model.rowCount() == 5); + CHECK(f.model.rowCount(f.model.index(0)) == 0); + CHECK_FALSE(f.model.data({}).isValid()); + CHECK_FALSE(f.model.setData({}, Qt::Checked, ModListModel::kCheckStateRole)); +} +TEST_CASE("mod list check state emits role updates without resetting") { + ListFixture f; + QSignalSpy reset(&f.model, &QAbstractItemModel::modelReset); + QSignalSpy changed(&f.model, &QAbstractItemModel::dataChanged); + QSignalSpy active(&f.model, &ModListModel::activeModsChanged); + REQUIRE(f.model.setData(f.model.index(2), Qt::Checked, + ModListModel::kCheckStateRole)); + CHECK(f.model.getItem(2)->checked); + CHECK(f.indices() == QList{1, 2, 3, -1, -1}); + CHECK(reset.isEmpty()); + REQUIRE(changed.size() == 1); + CHECK(changed.first()[2].value>().contains( + ModListModel::kLoadIndexRole)); + CHECK(active.size() == 1); + changed.clear(); + REQUIRE(f.model.setData(f.model.index(2), Qt::Checked, + ModListModel::kCheckStateRole)); + CHECK(changed.isEmpty()); + f.model.setCheckStates({0}, false); + CHECK(f.indices() == QList{-1, 1, 2, -1, -1}); +} +TEST_CASE( + "mod list commits subset order preserving persistent UUID identities") { + ListFixture f; + QPersistentModelIndex persistent(f.model.index(0)); + QSignalSpy about(&f.model, &QAbstractItemModel::layoutAboutToBeChanged); + QSignalSpy changed(&f.model, &QAbstractItemModel::layoutChanged); + f.model.commitOrder({"uuid-2", "uuid-0"}); + CHECK(f.order() == + QStringList{"uuid-2", "uuid-0", "uuid-1", "uuid-3", "uuid-4"}); + CHECK(persistent.row() == 1); + CHECK(persistent.data(ModListModel::kUuidRole).toString() == "uuid-0"); + CHECK(about.size() == 1); + CHECK(changed.size() == 1); + f.model.commitOrder(f.order()); + CHECK(changed.size() == 1); + f.model.commitOrder({"uuid-4", "uuid-3", "uuid-1", "uuid-0", "uuid-2"}); + CHECK(f.order() == + QStringList{"uuid-4", "uuid-3", "uuid-1", "uuid-0", "uuid-2"}); + CHECK(f.model.activeUuids() == QStringList{"uuid-0", "uuid-2"}); +} +TEST_CASE("mod list move updates load indices and rejects invalid moves") { + ListFixture f; + QSignalSpy changed(&f.model, &QAbstractItemModel::dataChanged); + CHECK(f.indices() == QList{1, 2, -1, -1, -1}); + REQUIRE(f.model.moveRow(0, 1)); + CHECK(f.order().first() == "uuid-2"); + CHECK(f.indices() == QList{1, 2, -1, -1, -1}); + REQUIRE_FALSE(changed.isEmpty()); + CHECK(changed.first()[2].value>().contains( + ModListModel::kLoadIndexRole)); + CHECK_FALSE(f.model.moveRow(0, 0)); + CHECK_FALSE(f.model.moveRow(-1, 1)); + CHECK_FALSE(f.model.moveRow(0, 5)); +} +TEST_CASE("mod list provider labels and QML roles match the reference") { + CHECK(providerLabel("steam") == "Workshop"); + CHECK(providerLabel("downloaded") == "Workshop"); + CHECK(providerLabel("local") == "Local"); + CHECK(providerLabel("core", "ludeon.rimworld") == "Core"); + CHECK(providerLabel("core", "ludeon.rimworld.royalty") == "DLC"); + CHECK(providerLabel("unknown", "ludeon.rimworld.ideology") == "DLC"); + ListFixture f; + const auto roles = f.model.roleNames(); + CHECK(roles.value(ModListModel::kCheckStateRole) == "checkState"); + CHECK(roles.value(ModListModel::kSectionNameRole) == "sectionName"); + CHECK(roles.value(ModListModel::kStartupImpactTextRole) == + "startupImpactText"); + CHECK(f.model.data(f.model.index(0), ModListModel::kProviderColorRole) + .toString() == "#abcdef"); + CHECK(f.model.data(f.model.index(2), ModListModel::kSectionNameRole) + .toString() == "Inactive (3)"); +} +TEST_CASE( + "mod list startup impacts include presentation roles and empty input is " + "silent") { + ModListModel empty; + QSignalSpy noChanges(&empty, &QAbstractItemModel::dataChanged); + empty.setStartupImpact({{"mod.0", 1.5}}); + CHECK(noChanges.isEmpty()); + ListFixture f; + f.model.setStartupImpact({{"mod.0", 1.5}}); + CHECK(f.model.data(f.model.index(0), ModListModel::kStartupImpactRole) + .toDouble() == 1.5); + CHECK(f.model.data(f.model.index(0), ModListModel::kStartupImpactTextRole) + .toString() == "1.50 s"); + CHECK(f.model.data(f.model.index(1), ModListModel::kStartupImpactRole) + .toDouble() == 0); + CHECK(formatDuration(0.0001) == "0 ms"); + CHECK(formatDuration(0.5) == "500 ms"); + CHECK(formatDuration(60) == "1m 00s"); + CHECK(impactColor(0.5) == paletteValue(u"SUCCESS")); + CHECK(impactColor(1) == paletteValue(u"WARNING")); + CHECK(impactColor(5) == paletteValue(u"DANGER")); +} diff --git a/tests/ui/test_mod_list_panel.cpp b/tests/ui/test_mod_list_panel.cpp new file mode 100644 index 0000000..ffd6120 --- /dev/null +++ b/tests/ui/test_mod_list_panel.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../core/services/services_support.hpp" +#include "core/context.hpp" +#include "core/services/diagnostics_service.hpp" +#include "test_support.hpp" +#include "ui/components/mod_activation.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/panels/mod_list_panel.hpp" +#include "ui/theme/qml_theme.hpp" +using px::modrim::ModListPanel; +namespace { +struct PanelFixture { + std::unique_ptr ctx; + QWidget parent; + QQmlEngine* engine; + std::unique_ptr panel; + PanelFixture(bool active = true, bool dependencies = false) { + ctx = px::ui_test::makeCore(); + QHash mods; + for (int i = 0; i < 3; ++i) { + const auto letter = QChar('a' + i); + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(QChar('A' + i)); + mod->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(letter)); + mod->providerId = QStringLiteral("stub"); + if (dependencies && i > 0) { + px::modrim::DependencyMod dependency; + dependency.packageId = px::modrim::CaseInsensitiveStr( + QStringLiteral("mod.%1").arg(QChar('a' + i - 1))); + mod->aboutRules.dependencies.append(dependency); + } + mods.insert(QStringLiteral("uuid-%1").arg(letter), mod); + } + ctx->load(mods, active ? QStringList{"uuid-a", "uuid-b", "uuid-c"} + : QStringList{}); + ctx->diagnosticsService().rebuild(); + engine = px::modrim::createQmlEngine(&parent); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + panel = std::make_unique(*ctx, engine, &parent); + panel->loadMods(mods, ctx->activeUuids()); + } + QObject* root() const { return panel->qmlWidget()->rootObject(); } + QObject* view() const { + return root() ? root()->findChild(QStringLiteral("listView")) + : nullptr; + } + void select(int row, const QString& uuid) const { + REQUIRE(root()); + REQUIRE(QMetaObject::invokeMethod(root(), "selectRow", Qt::DirectConnection, + Q_ARG(QVariant, QVariant(row)), + Q_ARG(QVariant, QVariant(uuid)), + Q_ARG(QVariant, QVariant(0)))); + } + void show() const { + panel->resize(420, 320); + panel->show(); + QTest::qWait(20); + REQUIRE(panel->qmlWidget()->status() == QQuickWidget::Ready); + REQUIRE(root()); + REQUIRE(view()); + } +}; +QVariant qmlValue(QObject* object, const char* name) { + const auto value = object->property(name); + return value.metaType() == QMetaType::fromType() + ? value.value().toVariant() + : value; +} +void toggleWithAnswer(PanelFixture& f, const QList& rows, + QMessageBox::StandardButton answer, + QString* prompt = nullptr) { + QTimer click(&f.parent); + click.setInterval(1); + QObject::connect(&click, &QTimer::timeout, &f.parent, [&] { + for (auto* box : f.panel->findChildren()) + if (box->isVisible()) { + if (prompt) *prompt = box->text(); + click.stop(); + box->button(answer)->click(); + return; + } + }); + click.start(); + services_test::runAsync(f.panel->toggleRows(rows)); +} +} // namespace +TEST_CASE( + "disabling dependencies supports selected only all dependents and " + "cancellation") { + SECTION("selected only") { + PanelFixture f(true, true); + QString prompt; + toggleWithAnswer(f, {0}, QMessageBox::No, &prompt); + CHECK(f.panel->activeUuids() == QStringList{"uuid-b", "uuid-c"}); + CHECK(prompt.contains("Mod B (mod.b)")); + CHECK(prompt.contains("Mod C (mod.c)")); + } + SECTION("selected plus transitive dependents") { + PanelFixture f(true, true); + toggleWithAnswer(f, {0, 1}, QMessageBox::Yes); + CHECK(f.panel->activeUuids().isEmpty()); + } + SECTION("cancel") { + PanelFixture f(true, true); + toggleWithAnswer(f, {0}, QMessageBox::Cancel); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a", "uuid-b", "uuid-c"}); + } +} +TEST_CASE( + "disabled mods leave the active block and reenabling restores dependency " + "position") { + PanelFixture f(true, true); + toggleWithAnswer(f, {1}, QMessageBox::No); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a", "uuid-c"}); + const auto* inactive = f.panel->model()->getItem(2); + REQUIRE(inactive); + CHECK(inactive->uuid == "uuid-b"); + CHECK_FALSE(inactive->checked); + CHECK(inactive->loadIndex == -1); + services_test::runAsync(f.panel->toggleRows({f.panel->rowForUuid("uuid-b")})); + CHECK(f.panel->activeUuids() == QStringList{"uuid-a", "uuid-b", "uuid-c"}); + CHECK(f.ctx->activeUuids() == f.panel->activeUuids()); +} +TEST_CASE("hover and tiny drag handle jitter never display the drag proxy") { + PanelFixture f; + f.show(); + auto* dragProxy = f.root()->findChild(QStringLiteral("dragProxy")); + REQUIRE(dragProxy); + auto* widget = f.panel->qmlWidget(); + QTest::mouseMove(widget, QPoint(4, 26)); + QTest::mouseMove(widget, QPoint(14, 26)); + CHECK_FALSE(dragProxy->property("visible").toBool()); + QTest::mousePress(widget, Qt::LeftButton, Qt::NoModifier, QPoint(20, 26)); + QTest::mouseMove(widget, QPoint(21, 26)); + CHECK_FALSE(dragProxy->property("visible").toBool()); + QTest::mouseRelease(widget, Qt::LeftButton, Qt::NoModifier, QPoint(21, 26)); +} +TEST_CASE("highlights preserve user selection across filter changes") { + PanelFixture f; + f.select(0, "uuid-a"); + f.panel->highlightRows({"uuid-c"}); + f.panel->setSearchFilter("Mod"); + f.panel->setSearchFilter(""); + CHECK(f.panel->isHighlighted("uuid-c")); + CHECK_FALSE(f.panel->isHighlighted("uuid-a")); + REQUIRE(f.view()); + CHECK(qmlValue(f.view(), "selectedUuids").toList() == + QVariantList{QStringLiteral("uuid-a")}); + CHECK(f.view()->property("currentUuid").toString() == "uuid-a"); +} +TEST_CASE("highlight also changes the selected delegate tint") { + PanelFixture f; + f.show(); + f.select(0, "uuid-a"); + QTest::qWait(1); + auto colors = [&] { + QSet result; + auto* content = f.view()->property("contentItem").value(); + REQUIRE(content); + for (auto* item : content->childItems()) + if (item->property("color").isValid()) + result.insert( + item->property("color").value().name(QColor::HexArgb)); + return result; + }; + const auto before = colors(); + f.panel->highlightRows({"uuid-a"}); + QTest::qWait(1); + CHECK(colors() != before); +} +TEST_CASE( + "QML selection clears when filters hide current UUID and empty state " + "appears") { + PanelFixture f(false); + REQUIRE(f.view()); + CHECK(f.view()->property("currentIndex").toInt() == -1); + f.select(2, "uuid-c"); + f.panel->setSearchFilter("Mod A"); + CHECK(f.view()->property("count").toInt() == 1); + CHECK(f.view()->property("currentIndex").toInt() == -1); + CHECK(f.view()->property("anchorIndex").toInt() == -1); + CHECK(qmlValue(f.view(), "selectedIndices").toList().isEmpty()); + f.panel->setSearchFilter("not present"); + CHECK(f.view()->property("count").toInt() == 0); + auto* empty = f.root()->findChild(QStringLiteral("emptyStateText")); + REQUIRE(empty); + CHECK(empty->property("visible").toBool()); +} +TEST_CASE( + "QML selection follows UUID during a row move and reveal respects " + "filters") { + PanelFixture f; + f.select(2, "uuid-c"); + f.panel->moveRow(2, 0); + CHECK(f.panel->uuidAt(0) == "uuid-c"); + REQUIRE(f.view()); + CHECK(f.view()->property("currentIndex").toInt() == 0); + CHECK(qmlValue(f.view(), "selectedIndices").toList() == QVariantList{0}); + CHECK(qmlValue(f.view(), "selectedUuids").toList() == + QVariantList{QStringLiteral("uuid-c")}); + CHECK(f.panel->selectUuid("uuid-b")); + f.panel->setSearchFilter("Mod A"); + CHECK_FALSE(f.panel->selectUuid("uuid-b")); +} +TEST_CASE( + "search focus disables list keyboard actions and Space remains input " + "text") { + PanelFixture f(false); + f.show(); + f.parent.show(); + REQUIRE(QTest::qWaitForWindowActive(&f.parent)); + QTest::mouseClick(f.panel->qmlWidget(), Qt::LeftButton, Qt::NoModifier, + QPoint(100, 26)); + QTest::qWait(1); + CHECK(f.root()->property("keyboardActive").toBool()); + f.select(0, "uuid-a"); + f.panel->searchInput->setFocus(); + QTest::qWait(1); + CHECK_FALSE(f.root()->property("keyboardActive").toBool()); + QTest::keyClick(f.panel->searchInput, Qt::Key_Return); + CHECK(f.panel->activeUuids().isEmpty()); + QTest::keyClick(f.panel->searchInput, Qt::Key_Space); + CHECK(f.panel->searchInput->text() == " "); + CHECK(f.panel->activeUuids().isEmpty()); +} diff --git a/tests/ui/test_mod_list_proxy_model.cpp b/tests/ui/test_mod_list_proxy_model.cpp new file mode 100644 index 0000000..37129bf --- /dev/null +++ b/tests/ui/test_mod_list_proxy_model.cpp @@ -0,0 +1,174 @@ +#include +#include + +#include + +#include "ui/models/mod_list_proxy_model.hpp" +using namespace px::modrim; +namespace { +struct ProxyFixture { + ModListModel source; + ModListProxyModel proxy{&source}; + ProxyFixture() { + QHash mods; + for (int i = 0; i < 5; ++i) { + auto mod = std::make_shared(); + mod->name = QStringLiteral("Mod %1").arg(i); + mod->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("mod.%1").arg(i)); + mods.insert(QStringLiteral("uuid-%1").arg(i), mod); + } + source.loadMods(mods, {"uuid-0", "uuid-2"}); + } + QList sourceRows() const { + QList rows; + for (int row = 0; row < proxy.rowCount(); ++row) + rows.append(proxy.mapToSource(proxy.index(row)).row()); + return rows; + } + void checkRoundTrip() const { + for (int row = 0; row < proxy.rowCount(); ++row) { + const auto sourceIndex = proxy.mapToSource(proxy.index(row)); + REQUIRE(sourceIndex.isValid()); + CHECK(proxy.mapFromSource(sourceIndex).row() == row); + } + } +}; +} // namespace +TEST_CASE("mod proxy sidebar filtering maps subset empty and cleared states") { + ProxyFixture f; + CHECK(f.proxy.rowCount() == 5); + f.proxy.setSidebarFilter(QSet{"uuid-0", "uuid-2", "uuid-4"}); + CHECK(f.sourceRows() == QList{0, 1, 4}); + f.checkRoundTrip(); + f.proxy.setSidebarFilter(QSet{}); + CHECK(f.proxy.rowCount() == 0); + f.proxy.setSidebarFilter(std::nullopt); + CHECK(f.proxy.rowCount() == 5); +} +TEST_CASE( + "mod proxy search normalizes case whitespace package IDs and Unicode") { + ProxyFixture f; + f.proxy.setSearchFilter("Mod 1"); + CHECK(f.sourceRows() == QList{2}); + f.proxy.setSearchFilter("mod.3"); + CHECK(f.sourceRows() == QList{3}); + f.proxy.setSearchFilter("MOD 2"); + CHECK(f.sourceRows() == QList{1}); + f.proxy.setSearchFilter("zzzz"); + CHECK(f.proxy.rowCount() == 0); + f.proxy.setSearchFilter(" \t \n "); + CHECK(f.proxy.rowCount() == 5); + auto unicode = std::make_shared(); + unicode->name = QStringLiteral("Große Mod"); + unicode->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral("grosse.mod")); + auto padded = std::make_shared(); + padded->name = QStringLiteral(" Padded Mod "); + padded->packageId = + px::modrim::CaseInsensitiveStr(QStringLiteral(" padded.pkg ")); + f.source.loadMods({{"u", unicode}, {"p", padded}}, {"u", "p"}); + f.proxy.setSearchFilter(QStringLiteral(" GROßE ")); + CHECK(f.sourceRows() == QList{0}); + f.proxy.setSearchFilter(" padded.pkg "); + CHECK(f.sourceRows() == QList{1}); +} +TEST_CASE( + "mod proxy combines search and sidebar filters and exposes transitions") { + ProxyFixture f; + QSignalSpy filtered(&f.proxy, &ModListProxyModel::isFilteredChanged); + f.proxy.setSidebarFilter(QSet{"uuid-1", "uuid-2", "uuid-3"}); + f.proxy.setSearchFilter("Mod 2"); + CHECK(f.sourceRows() == QList{1}); + CHECK(f.proxy.isFiltered()); + CHECK(filtered.size() == 1); + f.proxy.setSearchFilter("Mod 0"); + CHECK(f.proxy.rowCount() == 0); + f.proxy.setSearchFilter(""); + CHECK(f.proxy.rowCount() == 3); + CHECK(filtered.size() == 1); + f.proxy.setSidebarFilter(std::nullopt); + CHECK_FALSE(f.proxy.isFiltered()); + CHECK(filtered.size() == 2); + f.proxy.setSearchFilter("mod"); + CHECK(filtered.size() == 3); + f.proxy.setSearchFilter(""); + CHECK(filtered.size() == 4); +} +TEST_CASE("mod proxy preserves persistent UUIDs that survive filtering") { + ProxyFixture f; + QPersistentModelIndex kept(f.proxy.index(2)); + QPersistentModelIndex hidden(f.proxy.index(1)); + f.proxy.setSidebarFilter(QSet{"uuid-1", "uuid-3"}); + CHECK(kept.isValid()); + CHECK(kept.row() == 0); + CHECK(kept.data(ModListModel::kUuidRole).toString() == "uuid-1"); + CHECK_FALSE(hidden.isValid()); + CHECK_FALSE(f.proxy.mapFromSource(f.source.index(0)).isValid()); + f.proxy.setSidebarFilter(std::nullopt); + f.proxy.setSearchFilter("Mod 4"); + CHECK(f.sourceRows() == QList{4}); + f.proxy.setSearchFilter(""); + f.checkRoundTrip(); +} +TEST_CASE("mod proxy moves retain source mappings with and without filters") { + ProxyFixture f; + REQUIRE(f.proxy.moveRow(0, 2)); + f.checkRoundTrip(); + ProxyFixture filtered; + filtered.proxy.setSidebarFilter(QSet{"uuid-0", "uuid-1", "uuid-4"}); + REQUIRE(filtered.proxy.moveRow(0, 1)); + filtered.checkRoundTrip(); + CHECK_FALSE(filtered.proxy.mapFromSource(filtered.source.index(0)).isValid()); +} +TEST_CASE("mod proxy clamped move stays inside the unfiltered active block") { + ProxyFixture f; + REQUIRE(f.proxy.activeCount() == 2); + REQUIRE(f.proxy.moveRow(0, 4, true)); + CHECK(f.proxy.data(f.proxy.index(0), ModListModel::kUuidRole).toString() == + "uuid-2"); + CHECK(f.proxy.data(f.proxy.index(1), ModListModel::kUuidRole).toString() == + "uuid-0"); + CHECK(f.proxy.data(f.proxy.index(2), ModListModel::kUuidRole).toString() == + "uuid-1"); + CHECK_FALSE(f.proxy.moveRow(2, 0, true)); + f.proxy.setSearchFilter("mod"); + CHECK_FALSE(f.proxy.moveRow(0, 1, true)); + f.checkRoundTrip(); +} +TEST_CASE("mod proxy emits data changes only after move completion") { + ProxyFixture f; + f.source.loadMods({{"u0", std::make_shared()}, + {"u1", std::make_shared()}, + {"u2", std::make_shared()}}, + {"u0", "u1", "u2"}); + QStringList events; + QObject::connect(&f.proxy, &QAbstractItemModel::rowsAboutToBeMoved, &f.proxy, + [&] { events.append("begin"); }); + QObject::connect(&f.proxy, &QAbstractItemModel::rowsMoved, &f.proxy, + [&] { events.append("end"); }); + QObject::connect( + &f.proxy, &QAbstractItemModel::dataChanged, &f.proxy, + [&](const QModelIndex& top, const QModelIndex& bottom) { + events.append( + QStringLiteral("data:%1:%2").arg(top.row()).arg(bottom.row())); + }); + REQUIRE(f.proxy.moveRow(0, 2)); + CHECK(events == QStringList{"begin", "end", "data:0:2"}); + for (int row = 0; row < 3; ++row) + CHECK(f.proxy.data(f.proxy.index(row), ModListModel::kLoadIndexRole) + .toInt() == row + 1); +} +TEST_CASE( + "mod proxy section counts reflect visible rows rather than the whole " + "collection") { + ProxyFixture f; + f.proxy.setSidebarFilter(QSet{"uuid-0", "uuid-1"}); + CHECK(f.proxy.activeCount() == 1); + CHECK(f.proxy.data(f.proxy.index(1), ModListModel::kSectionNameRole) + .toString() == "Inactive (1)"); + f.proxy.setSidebarFilter(QSet{"uuid-1"}); + CHECK(f.proxy.data(f.proxy.index(0), ModListModel::kSectionNameRole) + .toString() + .isEmpty()); +} diff --git a/tests/ui/test_mod_tree_model.cpp b/tests/ui/test_mod_tree_model.cpp new file mode 100644 index 0000000..300cd7e --- /dev/null +++ b/tests/ui/test_mod_tree_model.cpp @@ -0,0 +1,420 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "ui/plugins/organizer/filter_model.hpp" +#include "ui/plugins/organizer/tree_model.hpp" + +using namespace px::modrim; +using namespace px::modrim::organizer; +using px::modrim::ModTreeModel; +using px::modrim::OrganizerFilterModel; + +namespace { +struct OrganizerFixture { + OrganizerState state; + ModsByUuid mods; + QSet active{QStringLiteral("u1")}; + + OrganizerFixture() { + state.folders.insert(kRootId, + {kRootId, std::nullopt, QStringLiteral("Root")}); + auto add = [&](const QString& uuid, const QString& name, const QString& pid, + const QString& provider) { + auto mod = std::make_shared(); + mod->name = name; + mod->packageId = CaseInsensitiveStr(pid); + mod->providerId = provider; + mods.insert(uuid, mod); + }; + add("u1", "One", "a.one", "local"); + add("u2", "Two", "a.two", "steam"); + add("u3", "Loose", "loose.mod", "local"); + } + + void nested() { + state.folders.insert(2, {2, kRootId, QStringLiteral("Top")}); + state.folders.insert(3, {3, 2, QStringLiteral("Sub")}); + state.placements.insert("a.one", 2); + state.placements.insert("a.two", 3); + } + + FolderNode tree(const std::optional& query = std::nullopt) const { + return buildTree(state, mods, active, query); + } +}; + +ModTreeModel::SummaryFor noDiagnostics = + [](const QString&) -> std::optional { + return std::nullopt; +}; +} // namespace + +TEST_CASE("organizer tree has nested folder rows and trailing ungrouped") { + OrganizerFixture f; + f.nested(); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + REQUIRE(model.rowCount() == 2); + const auto top = model.index(0, 0); + const auto ungrouped = model.index(1, 0); + CHECK(model.data(top).toString() == "Top"); + CHECK(model.data(ungrouped).toString() == "Ungrouped"); + CHECK(model.data(model.index(0, 0, top)).toString() == "Sub"); + CHECK(model.data(model.index(1, 0, top)).toString() == "One"); + CHECK(model.data(model.index(0, 0, ungrouped)).toString() == "Loose"); + CHECK(model.parent(model.index(0, 0, top)) == top); + CHECK(model.parent(model.index(0, 0, ungrouped)) == ungrouped); + CHECK_FALSE(model.parent(top).isValid()); + CHECK(model.data(ungrouped, ModTreeModel::kTotalCountRole).toInt() == 1); + CHECK(model.data(top, ModTreeModel::kTotalCountRole).toInt() == 2); + CHECK(model.nodeAt(top)->folderId() == 2); + CHECK(model.nodeAt(model.indexForUuid("u2"))->folderId() == 3); + CHECK(model.nodeAt(ungrouped)->folderId() == kRootId); + CHECK(model.nodeAt({}) == nullptr); + CHECK_FALSE(model.index(-1, 0).isValid()); + CHECK_FALSE(model.index(0, 1).isValid()); + CHECK_FALSE(model.indexForKey("missing").isValid()); + CHECK_FALSE(model.indexForUuid("missing").isValid()); + CHECK_FALSE(model.data({}).isValid()); + CHECK(model.columnCount() == 1); +} + +TEST_CASE("organizer check states retain total counts while filtering") { + OrganizerFixture f; + f.state.folders.insert(2, {2, kRootId, QStringLiteral("Group")}); + f.state.placements.insert("a.one", 2); + f.state.placements.insert("a.two", 2); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + auto group = model.index(0, 0); + CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == + Qt::PartiallyChecked); + CHECK(model.data(model.index(0, 0, group), ModTreeModel::kCheckStateRole) + .toInt() == Qt::Checked); + CHECK(model.data(model.index(1, 0, group), ModTreeModel::kCheckStateRole) + .toInt() == Qt::Unchecked); + CHECK(model.data(model.index(1, 0), ModTreeModel::kCheckStateRole).toInt() == + Qt::Unchecked); + TreeQuery query; + query.text = QStringLiteral("One"); + model.setTree(f.tree(query), f.mods, {}, true); + group = model.index(0, 0); + CHECK(model.rowCount() == 1); + CHECK(model.filtering()); + CHECK(model.data(group, ModTreeModel::kFilteringRole).toBool()); + CHECK(model.data(group, ModTreeModel::kVisibleCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::kTotalCountRole).toInt() == 2); + CHECK(model.data(group, ModTreeModel::kEnabledCountRole).toInt() == 1); + CHECK(model.data(group, ModTreeModel::kCheckStateRole).toInt() == + Qt::PartiallyChecked); + CHECK(model.indexForKey("f:2").isValid()); +} + +TEST_CASE("organizer issue markers only show on enabled mods") { + OrganizerFixture f; + view::ModDiagnosticsView summary{true, true, QStringLiteral("Error"), + QStringLiteral("Incompatible")}; + int calls = 0; + ModTreeModel model( + [&](const QString&) -> std::optional { + ++calls; + return summary; + }); + model.setTree(f.tree(), f.mods, {}, false); + const auto enabled = model.indexForUuid("u1"); + const auto inactive = model.indexForUuid("u2"); + CHECK(model.parent(enabled) == model.index(0, 0)); + CHECK(model.data(enabled, ModTreeModel::kHasErrorRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::kHasWarningRole).toBool()); + CHECK(model.data(enabled, ModTreeModel::kErrorTooltipRole).toString() == + "Error"); + CHECK(model.data(enabled, ModTreeModel::kWarningTooltipRole).toString() == + "Incompatible"); + calls = 0; + CHECK_FALSE(model.data(inactive, ModTreeModel::kHasErrorRole).toBool()); + CHECK_FALSE(model.data(inactive, ModTreeModel::kHasWarningRole).toBool()); + CHECK(model.data(inactive, ModTreeModel::kErrorTooltipRole) + .toString() + .isEmpty()); + CHECK(model.data(inactive, ModTreeModel::kWarningTooltipRole) + .toString() + .isEmpty()); + CHECK(calls == 0); + f.active.insert("u2"); + model.setTree(f.tree(), f.mods, {}, false); + CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kHasWarningRole) + .toBool()); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + model.refreshDiagnostics(); + REQUIRE(changed.count() == 2); + CHECK(changed.first()[2].value>() == + QList{ModTreeModel::kHasErrorRole, ModTreeModel::kErrorTooltipRole, + ModTreeModel::kHasWarningRole, + ModTreeModel::kWarningTooltipRole}); +} + +TEST_CASE( + "organizer same-shape updates preserve persistent indexes and selection") { + OrganizerFixture f; + f.nested(); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + QPersistentModelIndex one(model.indexForUuid("u1")); + model.select({one}); + model.select({model.indexForUuid("u2")}, "extend"); + REQUIRE(model.selectedNodes().size() == 2); + model.select({model.indexForUuid("u2")}, "toggle"); + CHECK_FALSE(model.isSelected(model.indexForUuid("u2"))); + CHECK(model.isSelected(one)); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + f.active.insert("u2"); + model.setTree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(one.isValid()); + CHECK(model.data(one).toString() == "One"); + CHECK(model.data(one, ModTreeModel::kSelectedRole).toBool()); + model.clearSelection(); + CHECK(model.selectedNodes().isEmpty()); + model.select({model.indexForUuid("u3")}); + TreeQuery query; + query.text = QStringLiteral("One"); + model.setTree(f.tree(query), f.mods, {}, true); + CHECK(reset.count() == 1); + CHECK_FALSE(one.isValid()); + CHECK(model.selectedNodes().isEmpty()); +} + +TEST_CASE( + "organizer expansion respects collapsed ancestors except when filtering") { + OrganizerFixture f; + f.nested(); + f.state.folders[2].collapsed = true; + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + auto expanded = model.expandedIndexes(); + REQUIRE(expanded.size() == 1); + CHECK(model.data(expanded.first(), ModTreeModel::kKeyRole).toString() == + "ungrouped"); + model.setTree(f.tree(), f.mods, {}, true); + expanded = model.expandedIndexes(); + REQUIRE(expanded.size() == 3); + CHECK(model.data(expanded[0], ModTreeModel::kKeyRole).toString() == "f:2"); + CHECK(model.data(expanded[1], ModTreeModel::kKeyRole).toString() == "f:3"); + CHECK(model.expandedIndexes(model.indexForKey("f:2")).size() == 1); +} + +TEST_CASE("organizer item keys resolve visible rows after collapse updates") { + OrganizerFixture f; + f.state.folders.insert(2, {2, kRootId, QStringLiteral("Earlier")}); + f.state.folders.insert(3, {3, kRootId, QStringLiteral("Later")}); + f.state.folders[2].collapsed = true; + f.state.placements.insert("a.one", 2); + f.state.placements.insert("a.two", 3); + ModTreeModel model(noDiagnostics); + model.setTree(f.tree(), f.mods, {}, false); + const auto anchorKey = + model.data(model.indexForUuid("u2"), ModTreeModel::kKeyRole).toString(); + const auto folderKey = + model.data(model.index(1, 0), ModTreeModel::kKeyRole).toString(); + + QQmlEngine engine; + QQmlComponent component(&engine); + component.setData(R"( + import QtQuick + TreeView { + required property var treeModel + model: treeModel + function rowForKey(key) { + return rowAtIndex(treeModel.indexForKey(key)) + } + function keyAtRow(row) { + return treeModel.data(index(row, 0), Qt.UserRole + 2) + } + } + )", + QUrl{}); + INFO(component.errorString().toStdString()); + REQUIRE(component.isReady()); + std::unique_ptr tree(component.createWithInitialProperties( + {{QStringLiteral("treeModel"), QVariant::fromValue(&model)}})); + REQUIRE(tree); + tree->setParent(&engine); + engine.globalObject().setProperty(QStringLiteral("tree"), + engine.newQObject(tree.get())); + auto evaluate = [&](const QString& expression) { + auto result = engine.evaluate(expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + return result; + }; + auto rowForKey = [&](const QString& key) { + QVariant row; + REQUIRE(QMetaObject::invokeMethod(tree.get(), "rowForKey", + Q_RETURN_ARG(QVariant, row), + Q_ARG(QVariant, key))); + return row.toInt(); + }; + + evaluate(QStringLiteral("tree.expand(1)")); + REQUIRE(rowForKey(anchorKey) == 2); + REQUIRE(rowForKey(folderKey) == 1); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + evaluate(QStringLiteral("tree.expand(0)")); + f.state.folders[2].collapsed = false; + model.setTree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(rowForKey(anchorKey) == 3); + CHECK(rowForKey(folderKey) == 2); + CHECK(evaluate(QStringLiteral("tree.keyAtRow(3)")).toString() == anchorKey); + CHECK(evaluate(QStringLiteral("tree.keyAtRow(2)")).toString() == folderKey); + + evaluate(QStringLiteral("tree.collapse(0)")); + f.state.folders[2].collapsed = true; + model.setTree(f.tree(), f.mods, {}, false); + CHECK(reset.isEmpty()); + CHECK(rowForKey(anchorKey) == 2); + CHECK(rowForKey(folderKey) == 1); + evaluate(QStringLiteral("tree.collapse(1)")); + CHECK(rowForKey(anchorKey) == -1); + CHECK(rowForKey(QStringLiteral("missing")) == -1); + f.mods.remove("u2"); + model.setTree(f.tree(), f.mods, {}, false); + CHECK_FALSE(model.indexForKey(anchorKey).isValid()); + CHECK(rowForKey(anchorKey) == -1); +} + +TEST_CASE("organizer provider metadata tags and rule badges expose QML roles") { + OrganizerFixture f; + f.nested(); + f.state.tags.insert(7, {7, QStringLiteral("Later"), QStringLiteral("blue")}); + f.state.tags.insert(2, {2, QStringLiteral("First"), QStringLiteral("red")}); + f.state.modTags.insert("a.one", {7, 2, 99}); + auto about = std::dynamic_pointer_cast(f.mods["u1"]); + about->modVersion = QStringLiteral("1.2.3"); + auto tree = f.tree(); + tree.children[0].hasRules = true; + tree.hasRules = true; + ModTreeModel model(noDiagnostics); + model.setTree(tree, f.mods, + {{QStringLiteral("local"), QStringLiteral("green")}}, false, + f.state.tags); + const auto one = model.indexForUuid("u1"); + CHECK(model.data(one, ModTreeModel::kUuidRole).toString() == "u1"); + CHECK(model.data(one, ModTreeModel::kPackageIdRole).toString() == "a.one"); + CHECK(model.data(one, ModTreeModel::kCanPlaceRole).toBool()); + CHECK(model.data(one, ModTreeModel::kProviderLabelRole).toString() == + "Local"); + CHECK(model.data(one, ModTreeModel::kProviderIconRole).toString() == + "folder"); + CHECK(model.data(one, ModTreeModel::kProviderColorRole).toString() == + "green"); + CHECK(model.data(one, ModTreeModel::kModVersionRole).toString() == "1.2.3"); + CHECK(model.data(model.indexForUuid("u2"), ModTreeModel::kProviderIconRole) + .toString() == "steam"); + const auto tags = model.data(one, ModTreeModel::kTagsRole).toList(); + REQUIRE(tags.size() == 2); + CHECK(tags[0].toMap()["name"].toString() == "First"); + CHECK(tags[1].toMap()["color"].toString() == "blue"); + CHECK(model.data(model.indexForKey("f:2"), ModTreeModel::kHasRuleRole) + .toBool()); + CHECK_FALSE( + model.data(model.indexForKey("ungrouped"), ModTreeModel::kHasRuleRole) + .toBool()); + CHECK(model.roleNames()[ModTreeModel::kProviderLabelRole] == "providerLabel"); + CHECK(model.roleNames()[ModTreeModel::kTagsRole] == "tags"); + + auto invalid = std::make_shared(); + invalid->providerId = QStringLiteral("git"); + f.mods.insert("invalid", invalid); + model.setTree(f.tree(), f.mods, {}, false); + const auto idx = model.indexForUuid("invalid"); + REQUIRE(idx.isValid()); + CHECK_FALSE(model.data(idx, ModTreeModel::kCanPlaceRole).toBool()); + CHECK(model.data(idx, ModTreeModel::kPackageIdRole).toString().isEmpty()); + CHECK(model.data(idx, ModTreeModel::kProviderIconRole).toString() == "git"); + CHECK(model.data(idx, ModTreeModel::kModVersionRole).toString().isEmpty()); +} + +TEST_CASE( + "organizer filter sections icons and fallbacks match sidebar entries") { + OrganizerFilterModel model; + CHECK(model.rowCount() == 0); + CHECK(model.forKey("missing").key == "all"); + CHECK_FALSE(model.keyAt(0)); + QList entries{ + {QStringLiteral("all"), QStringLiteral("All"), 10}, + {QStringLiteral("active"), QStringLiteral("Active"), 6, + StatusFilter::kActive}, + {QStringLiteral("inactive"), QStringLiteral("Inactive"), 4, + StatusFilter::kInactive}, + {QStringLiteral("provider:steam"), QStringLiteral("Steam"), 8, + StatusFilter::kAll, QStringLiteral("steam")}, + {QStringLiteral("tag:1"), QStringLiteral("Medieval"), 5, + StatusFilter::kAll, std::nullopt, 1}, + {QStringLiteral("tag:2"), QStringLiteral("Empty"), 0, StatusFilter::kAll, + std::nullopt, 2}}; + model.update(entries); + REQUIRE(model.rowCount() == 6); + const QStringList sections{"Status", "Status", "Status", + "Providers", "Tags", "Tags"}; + const QStringList icons{"grid", "check-circle", "ban", "steam", "tag", "tag"}; + for (int row = 0; row < 6; ++row) { + CHECK(model.data(model.index(row), OrganizerFilterModel::kSectionRole) + .toString() == sections[row]); + CHECK(model.data(model.index(row), OrganizerFilterModel::kIconRole) + .toString() == icons[row]); + } + CHECK(model.data(model.index(4), OrganizerFilterModel::kKeyRole).toString() == + "tag:1"); + CHECK(model.data(model.index(4)).toString() == "Medieval"); + CHECK( + model.data(model.index(4), OrganizerFilterModel::kLabelRole).toString() == + "Medieval"); + CHECK(model.data(model.index(4), OrganizerFilterModel::kCountRole).toInt() == + 5); + CHECK(model.data(model.index(5), OrganizerFilterModel::kCountRole).toInt() == + 0); + CHECK(model.forKey("tag:1").label == "Medieval"); + CHECK(model.forKey("tag:999").key == "all"); + CHECK(model.keyAt(4) == QStringLiteral("tag:1")); + CHECK_FALSE(model.keyAt(-1)); + CHECK_FALSE(model.keyAt(6)); + CHECK(model.rowCount(model.index(0)) == 0); + CHECK_FALSE(model.data({}).isValid()); + CHECK(model.roleNames()[Qt::DisplayRole] == "filterLabel"); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + entries[4].label = QStringLiteral("Medieval Overhaul"); + model.update(entries); + CHECK(reset.isEmpty()); + CHECK(changed.count() == 1); + CHECK(model.data(model.index(4)).toString() == "Medieval Overhaul"); + entries.removeLast(); + model.update(entries); + CHECK(reset.count() == 1); +} + +TEST_CASE( + "organizer filter infers sections from keys and unknown providers use " + "folders") { + OrganizerFilterModel model; + model.update( + {{QStringLiteral("provider:custom"), QStringLiteral("Custom"), 1}, + {QStringLiteral("tag:9"), QStringLiteral("Tag"), 1}}); + CHECK(model.data(model.index(0), OrganizerFilterModel::kSectionRole) + .toString() == "Providers"); + CHECK( + model.data(model.index(0), OrganizerFilterModel::kIconRole).toString() == + "folder"); + CHECK(model.data(model.index(1), OrganizerFilterModel::kSectionRole) + .toString() == "Tags"); + CHECK( + model.data(model.index(1), OrganizerFilterModel::kIconRole).toString() == + "tag"); +} diff --git a/tests/ui/test_mod_updates.cpp b/tests/ui/test_mod_updates.cpp new file mode 100644 index 0000000..c840902 --- /dev/null +++ b/tests/ui/test_mod_updates.cpp @@ -0,0 +1,35 @@ +#include +#include +#include +#include + +#include + +#include "../core/services/services_support.hpp" +#include "ui/components/mod_updates.hpp" + +TEST_CASE("small updates skip confirmation") { + QWidget parent; + CHECK(services_test::runAsync( + px::modrim::confirmUpdate(px::modrim::kConfirmThreshold - 1, &parent))); + CHECK(parent.findChildren().isEmpty()); +} +TEST_CASE("large updates follow the actual confirmation dialog answer") { + QWidget parent; + for (const auto answer : {QMessageBox::Yes, QMessageBox::Cancel}) { + QTimer click(&parent); + click.setInterval(1); + QObject::connect(&click, &QTimer::timeout, &parent, [&] { + for (auto* box : parent.findChildren()) + if (box->isVisible()) { + click.stop(); + box->button(answer)->click(); + return; + } + }); + click.start(); + CHECK(services_test::runAsync(px::modrim::confirmUpdate( + px::modrim::kConfirmThreshold, &parent)) == + (answer == QMessageBox::Yes)); + } +} diff --git a/tests/ui/test_navigation.cpp b/tests/ui/test_navigation.cpp new file mode 100644 index 0000000..0ca2902 --- /dev/null +++ b/tests/ui/test_navigation.cpp @@ -0,0 +1,34 @@ +#include +#include + +#include + +#include "ui/navigation.hpp" + +using px::modrim::buildRoute; +using px::modrim::parseRoute; +using px::modrim::Route; + +TEST_CASE("route round-trips ids with reserved characters") { + const QString url = + buildRoute(QStringLiteral("workshop"), + {QStringLiteral("collection"), QStringLiteral("picked:a/b")}); + CHECK(url == QStringLiteral("modrim://workshop/collection/picked%3Aa%2Fb")); + CHECK(parseRoute(url) == + Route{QStringLiteral("workshop"), + {QStringLiteral("collection"), QStringLiteral("picked:a/b")}}); +} + +TEST_CASE("view-only route has an empty path") { + CHECK(parseRoute(QStringLiteral("modrim://downloads")) == + Route{QStringLiteral("downloads"), {}}); + CHECK(parseRoute(QStringLiteral("modrim://downloads/")) == + Route{QStringLiteral("downloads"), {}}); +} + +TEST_CASE("non-routes are rejected") { + for (const char* url : {"downloads", "https://downloads", "modrim://", ""}) { + CAPTURE(url); + CHECK_FALSE(parseRoute(QString::fromUtf8(url)).has_value()); + } +} diff --git a/tests/ui/test_organizer_dialogs.cpp b/tests/ui/test_organizer_dialogs.cpp new file mode 100644 index 0000000..6688b84 --- /dev/null +++ b/tests/ui/test_organizer_dialogs.cpp @@ -0,0 +1,31 @@ +#include +#include +#include +#include +#include + +#include + +#include "ui/plugins/organizer/dialogs.hpp" + +using px::modrim::DeleteFolderDialog; + +namespace { + +// The shared QApplication is created by tests/ui/main.cpp. +QApplication& widgetApp() { + return *static_cast(QApplication::instance()); +} + +} // namespace + +TEST_CASE("delete folder dialog confirms with yes button") { + widgetApp(); + QWidget parent; + DeleteFolderDialog dialog(QStringLiteral("Fixes"), &parent); + auto* yes = qobject_cast(dialog.button(QMessageBox::Yes)); + REQUIRE(yes != nullptr); + CHECK(yes->text() == QStringLiteral("Delete")); + yes->click(); + CHECK(dialog.result() == static_cast(QMessageBox::Yes)); +} diff --git a/tests/ui/test_organizer_rule_editor.cpp b/tests/ui/test_organizer_rule_editor.cpp new file mode 100644 index 0000000..b712c4c --- /dev/null +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -0,0 +1,429 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../core/organizer/test_util.hpp" +#include "core/context.hpp" +#include "core/organizer/defaults.hpp" +#include "test_support.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/context.hpp" +#include "ui/plugins/organizer/tree_model.hpp" +#include "ui/plugins/organizer/view.hpp" +#include "ui/theme/qml_theme.hpp" + +namespace { +using namespace px::modrim; +using namespace px::modrim::organizer; +using namespace px::modrim; +using organizer_test::expect; +using organizer_test::wait; + +// UI tests may share a QApplication supplied by their test executable. +class Application { + public: + Application() { + if (!QCoreApplication::instance()) + m_app = std::make_unique(m_argc, m_argv); + REQUIRE(qobject_cast(QCoreApplication::instance())); + } + + private: + int m_argc = 1; + char m_name[16] = "organizer-test"; + char* m_argv[2] = {m_name, nullptr}; + std::unique_ptr m_app; +}; + +template +void until(Predicate predicate) { + QElapsedTimer timer; + timer.start(); + while (!predicate() && timer.elapsed() < 5000) QTest::qWait(10); + REQUIRE(predicate()); +} + +QQuickItem* item(QQuickItem* root, const QString& name) { + if (root->objectName() == name) return root; + for (auto* child : root->childItems()) { + if (auto* found = item(child, name)) return found; + } + return nullptr; +} + +struct Harness { + Application application; + QTemporaryDir dir; + std::unique_ptr ctx = px::ui_test::makeCore(AppConfig{}); + OrganizerDb db{dir.filePath(QStringLiteral("organizer.db"))}; + OrganizerService* service = nullptr; + std::unique_ptr engine{ + createQmlEngine(QCoreApplication::instance())}; + AppContext app{*ctx}; + QWidget parent; + std::unique_ptr view; + QQuickItem* editor = nullptr; + + Harness() { + REQUIRE(dir.isValid()); + auto mod = std::make_shared(); + mod->name = QStringLiteral("Example"); + mod->providerId = QStringLiteral("local"); + mod->packageId = CaseInsensitiveStr(QStringView(u"example.mod")); + ctx->load({{QStringLiteral("example"), mod}}, {}); + auto plugin = std::make_unique(*ctx, db); + service = plugin.get(); + ctx->registerPlugin(std::move(plugin)); + wait(service->init()); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new Theme(engine.get())); + view = + std::make_unique(*ctx, engine.get(), &parent, app); + parent.resize(920, 700); + view->resize(parent.size()); + parent.show(); + view->show(); + view->rebuild(); + } + + ~Harness() { + view.reset(); + wait(service->shutdown()); + } + + QJSValue call(const QString& expression) { + engine->globalObject().setProperty(QStringLiteral("editor"), + engine->newQObject(editor)); + auto result = engine->evaluate(QStringLiteral("editor.") + expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + return result; + } + + void open(int count) { + view->openRules(); + auto* quick = view->findChild( + QStringLiteral("organizerRuleEditorQuickWidget")); + REQUIRE(quick); + until([&] { return quick->status() != QQuickWidget::Loading; }); + INFO((quick->errors().isEmpty() + ? std::string{} + : quick->errors().first().toString().toStdString())); + REQUIRE(quick->status() == QQuickWidget::Ready); + editor = quick->rootObject(); + REQUIRE(editor); + until([&] { + return editor->property("opened").toBool() && draft().size() == count; + }); + } + + QVariantList draft() { + return call(QStringLiteral("draftRules()")).toVariant().toList(); + } + + void pattern(int index, const QString& value) { + REQUIRE(QMetaObject::invokeMethod(editor, "setRulePattern", + Q_ARG(QVariant, index), + Q_ARG(QVariant, value))); + } + + QQuickItem* row(int index) { + QQuickItem* result = nullptr; + until([&] { + auto visit = [&](auto&& self, QQuickItem* node) -> void { + if (node->objectName() == QStringLiteral("organizerRuleRow") && + node->property("index").toInt() == index) + result = node; + for (auto* child : node->childItems()) self(self, child); + }; + visit(visit, editor); + return result != nullptr; + }); + return result; + } + + void choose(int index, const QString& name, int choice) { + auto* control = item(row(index), name); + REQUIRE(control); + REQUIRE(control->setProperty("currentIndex", choice)); + REQUIRE( + QMetaObject::invokeMethod(control, "activated", Q_ARG(int, choice))); + } + + void save() { + call(QStringLiteral("submitRules()")); + until([&] { return !editor->property("opened").toBool(); }); + } +}; + +void checkRule(const Rule& rule, RuleOp op, const QString& pattern, + qint64 folder) { + CHECK(rule.field == RuleField::kPackageId); + CHECK(rule.op == op); + CHECK(rule.pattern == pattern); + CHECK(rule.folderId == folder); +} +} // namespace + +TEST_CASE("organizer rule editor preserves order and retains an invalid draft", + "[ui][organizer]") { + Harness h; + const auto first = + expect(wait(h.service->createFolder(QStringLiteral("First")))); + const auto second = + expect(wait(h.service->createFolder(QStringLiteral("Second")))); + h.view->rebuild(); + h.open(0); + h.call(QStringLiteral("addRule()")); + h.pattern(0, QStringLiteral("example")); + h.save(); + auto state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 1); + checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), + first.id); + CHECK(expect(h.service->folderModUuids(first.id)) == + QStringList{QStringLiteral("example")}); + + h.open(1); + h.call(QStringLiteral("addRule()")); + h.pattern(1, QStringLiteral("example.mod")); + h.choose(1, QStringLiteral("organizerRuleOperator"), 2); + const auto folders = h.view->ruleFolders(); + const auto target = + std::find_if(folders.begin(), folders.end(), [&](const QVariant& value) { + return value.toMap().value(QStringLiteral("id")).toLongLong() == + second.id; + }); + REQUIRE(target != folders.end()); + h.choose(1, QStringLiteral("organizerRuleFolder"), + static_cast(target - folders.begin())); + h.call(QStringLiteral("moveUp(1)")); + REQUIRE(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == + QStringLiteral("example.mod")); + h.save(); + state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 2); + checkRule(state.rules[0], RuleOp::kEquals, QStringLiteral("example.mod"), + second.id); + checkRule(state.rules[1], RuleOp::kPrefix, QStringLiteral("example"), + first.id); + CHECK(expect(h.service->folderModUuids(second.id)) == + QStringList{QStringLiteral("example")}); + + const auto saved = state.rules; + h.open(2); + h.pattern(0, QStringLiteral(" ")); + h.call(QStringLiteral("submitRules()")); + until([&] { + return h.editor->property("errorMessage") + .toString() + .contains(QStringLiteral("pattern cannot be empty")); + }); + CHECK(h.editor->property("opened").toBool()); + CHECK(h.draft()[0].toMap().value(QStringLiteral("pattern")).toString() == + QStringLiteral(" ")); + CHECK(h.draft()[1].toMap().value(QStringLiteral("pattern")).toString() == + QStringLiteral("example")); + CHECK(expect(wait(h.db.load())).rules == saved); + h.call(QStringLiteral("removeRule(0)")); + h.save(); + state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == 1); + checkRule(state.rules[0], RuleOp::kPrefix, QStringLiteral("example"), + first.id); + CHECK(expect(h.service->folderModUuids(first.id)) == + QStringList{QStringLiteral("example")}); +} + +TEST_CASE("organizer standard rules refresh only a clean editor draft", + "[ui][organizer]") { + Harness h; + expect(wait(h.service->createFolder(QStringLiteral("Mine")))); + h.view->rebuild(); + h.open(0); + auto* standard = item(h.editor, QStringLiteral("organizerAddStandardRules")); + REQUIRE(standard); + REQUIRE(standard->property("enabled").toBool()); + REQUIRE(QMetaObject::invokeMethod(standard, "clicked")); + until([&] { return h.draft().size() == standardRules().size(); }); + CHECK(h.editor->property("opened").toBool()); + CHECK(h.editor->property("errorMessage").toString().isEmpty()); + CHECK_FALSE(h.editor->property("draftDirty").toBool()); + const auto state = expect(wait(h.db.load())); + REQUIRE(state.rules.size() == standardRules().size()); + for (qsizetype i = 0; i < state.rules.size(); ++i) { + const auto& actual = state.rules[i]; + const auto& expected = standardRules()[i]; + CHECK(state.folders.value(actual.folderId).name == expected.folder); + CHECK(actual.field == expected.field); + CHECK(actual.op == expected.op); + CHECK(actual.pattern == expected.pattern); + CHECK(h.draft()[i].toMap().value(QStringLiteral("pattern")).toString() == + expected.pattern); + } + REQUIRE(standard->property("enabled").toBool()); + h.pattern(0, QStringLiteral("edited")); + until([&] { return !standard->property("enabled").toBool(); }); + CHECK(h.editor->property("draftDirty").toBool()); + CHECK(expect(wait(h.db.load())).rules == state.rules); +} + +TEST_CASE("organizer tag actions persist assignments and validated edits", + "[ui][organizer]") { + Harness h; + auto second = std::make_shared(); + second->name = QStringLiteral("Second"); + second->providerId = QStringLiteral("local"); + second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); + auto mods = h.ctx->allMods(); + mods.insert(QStringLiteral("second"), second); + h.ctx->load(std::move(mods), {}); + h.view->rebuild(); + auto* model = h.view->findChild(); + REQUIRE(model); + const auto select = [&](bool both) { + const auto first = model->indexForUuid(QStringLiteral("example")); + REQUIRE(first.isValid()); + h.view->selectSingle(first); + if (both) { + const auto other = model->indexForUuid(QStringLiteral("second")); + REQUIRE(other.isValid()); + h.view->selectRow(other, static_cast(Qt::ControlModifier), {}); + } + }; + // Invoke through QML so QmlTask-returning actions follow the real call path. + h.engine->globalObject().setProperty(QStringLiteral("panel"), + h.engine->newQObject(h.view.get())); + const auto action = [&](const QString& expression) { + const auto result = + h.engine->evaluate(QStringLiteral("panel.") + expression); + INFO(result.toString().toStdString()); + REQUIRE_FALSE(result.isError()); + }; + select(false); + action(QStringLiteral("createTag('Favorites', 'not-a-color')")); + until([&] { + return h.view->editorError().contains(QStringLiteral("valid color")); + }); + CHECK(h.service->state().tags.isEmpty()); + action(QStringLiteral("createTag('Favorites', '#3399aa')")); + until([&] { return h.service->state().tags.size() == 1; }); + const auto id = h.service->state().tags.constBegin().key(); + CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); + CHECK(h.service->state().tags.value(id).color == QStringLiteral("#3399aa")); + CHECK(h.view->editorError().isEmpty()); + const auto assign = [&](bool add) { + action(QStringLiteral("assignTag(%1, %2)") + .arg(id) + .arg(add ? QStringLiteral("true") : QStringLiteral("false"))); + }; + const auto checkState = [&] { + const auto rows = h.view->tagRows(); + REQUIRE(rows.size() == 1); + return rows[0].toMap().value(QStringLiteral("checkState")).toInt(); + }; + CHECK(checkState() == 0); + assign(true); + until([&] { + return h.service->state() + .modTags.value(QStringLiteral("example.mod")) + .contains(id); + }); + CHECK(checkState() == 1); + select(true); + CHECK(checkState() == 2); + assign(true); + until([&] { + return h.service->state() + .modTags.value(QStringLiteral("second.mod")) + .contains(id); + }); + CHECK(checkState() == 1); + auto persisted = expect(wait(h.db.load())); + CHECK(persisted.modTags.value(QStringLiteral("example.mod")) == + QSet{id}); + CHECK(persisted.modTags.value(QStringLiteral("second.mod")) == + QSet{id}); + assign(false); + until([&] { + return h.service->state() + .modTags.value(QStringLiteral("example.mod")) + .isEmpty() && + h.service->state() + .modTags.value(QStringLiteral("second.mod")) + .isEmpty(); + }); + CHECK(checkState() == 0); + action(QStringLiteral("updateTag(%1, 'Best', 'bad')").arg(id)); + until([&] { + return h.view->editorError().contains(QStringLiteral("valid color")); + }); + CHECK(h.service->state().tags.value(id).name == QStringLiteral("Favorites")); + CHECK(expect(wait(h.db.load())).tags.value(id).color == + QStringLiteral("#3399aa")); + action(QStringLiteral("updateTag(%1, 'Best', '#123456')").arg(id)); + until([&] { + return h.service->state().tags.value(id).name == QStringLiteral("Best"); + }); + CHECK(h.view->editorError().isEmpty()); + persisted = expect(wait(h.db.load())); + CHECK(persisted.tags.value(id).name == QStringLiteral("Best")); + CHECK(persisted.tags.value(id).color == QStringLiteral("#123456")); + CHECK(persisted.modTags.value(QStringLiteral("example.mod")).isEmpty()); + CHECK(persisted.modTags.value(QStringLiteral("second.mod")).isEmpty()); + action(QStringLiteral("deleteTag(%1)").arg(id)); + until([&] { return h.service->state().tags.isEmpty(); }); + CHECK(expect(wait(h.db.load())).tags.isEmpty()); +} + +TEST_CASE( + "organizer folder checkbox toggles descendants with one active-state " + "change", + "[ui][organizer]") { + Harness h; + auto second = std::make_shared(); + second->name = QStringLiteral("Second"); + second->providerId = QStringLiteral("local"); + second->packageId = CaseInsensitiveStr(QStringView(u"second.mod")); + auto mods = h.ctx->allMods(); + mods.insert(QStringLiteral("second"), second); + h.ctx->load(std::move(mods), {}); + const auto parent = + expect(wait(h.service->createFolder(QStringLiteral("Parent")))); + const auto child = + expect(wait(h.service->createFolder(QStringLiteral("Child"), parent.id))); + expect(wait(h.service->place({QStringLiteral("example.mod")}, child.id))); + expect(wait(h.service->place({QStringLiteral("second.mod")}, parent.id))); + h.view->rebuild(); + auto* model = h.view->findChild(); + REQUIRE(model); + int changes = 0; + const auto connection = h.ctx->activeStateChanged().connect( + [&](const QStringList&) { ++changes; }); + const auto toggle = [&] { + const auto index = + model->indexForKey(QStringLiteral("f:%1").arg(parent.id)); + REQUIRE(index.isValid()); + h.view->toggleCheck(index); + }; + toggle(); + until([&] { return h.ctx->activeUuids().size() == 2; }); + CHECK(h.ctx->activeUuids().contains(QStringLiteral("example"))); + CHECK(h.ctx->activeUuids().contains(QStringLiteral("second"))); + CHECK(changes == 1); + h.view->rebuild(); + toggle(); + until([&] { return h.ctx->activeUuids().isEmpty(); }); + CHECK(changes == 2); + h.ctx->activeStateChanged().disconnect(connection); +} diff --git a/tests/ui/test_procedural_preview.cpp b/tests/ui/test_procedural_preview.cpp new file mode 100644 index 0000000..66c36b8 --- /dev/null +++ b/tests/ui/test_procedural_preview.cpp @@ -0,0 +1,74 @@ +#include +#include +#include +#include + +#include + +#include "ui/components/procedural_preview.hpp" + +namespace { + +// generate_preview paints with QPainter and QFont, so it needs a GUI +// application. Reuse one if the test binary already has it; otherwise create +// one, offscreen unless the environment picks a platform. +QApplication& application() { + if (auto* app = qobject_cast(QCoreApplication::instance())) { + return *app; + } + static int argc = 1; + static char name[] = "pxmodrim_ui_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "offscreen"); + } + static QApplication app(argc, argv); + return app; +} + +} // namespace + +TEST_CASE("initials abbreviates titles") { + struct Case { + const char* title; + const char* expected; + }; + const Case cases[] = { + {"[AV] Framework - Russian Translation", "FR"}, + {"SF Grim Reality", "SG"}, + {"Vanilla Expanded Framework", "VE"}, + {"RimworldTweaks", "RT"}, + {"Combat", "CO"}, + {"[AV]", "AV"}, + {"[AV] Framework", "AF"}, + {"The Lord of Rings", "LR"}, + {"Русский перевод", "РП"}, + {"---", "--"}, + }; + for (const Case& c : cases) { + INFO("title: " << c.title); + CHECK(px::modrim::initials(QString::fromUtf8(c.title)) == + QString::fromUtf8(c.expected)); + } +} + +TEST_CASE("generate preview returns a non-null image of the requested size") { + application(); + const QImage image = + px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); + REQUIRE(!image.isNull()); + CHECK(image.width() == 120); + CHECK(image.height() == 60); +} + +TEST_CASE("generate preview returns the cached image for the same request") { + application(); + const QImage image = + px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); + REQUIRE(!image.isNull()); + // QImage shares its data implicitly, so a cache hit is identified by the + // shared data's cacheKey. + const QImage again = + px::modrim::generatePreview(QStringLiteral("[AV] Framework"), 120, 60); + CHECK(again.cacheKey() == image.cacheKey()); +} diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp new file mode 100644 index 0000000..2db6d6d --- /dev/null +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -0,0 +1,313 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/models/metadata/structures.hpp" +#include "core/mods_config.hpp" +#include "ui/panels/restore_snapshot_dialog.hpp" + +namespace { + +using px::modrim::CaseInsensitiveStr; +using px::modrim::ConfirmRestoreDialog; +using px::modrim::ModsConfig; +using px::modrim::RestoreSnapshotDialog; + +// Widgets need a QApplication for the whole run; the first test that asks for +// it creates it. +QApplication& ensureApp() { + if (auto* existing = + qobject_cast(QCoreApplication::instance())) + return *existing; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static int argc = 1; + static char name[] = "pxmodrim_tests"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; +} + +void writeText(const QString& path, const QByteArray& bytes) { + QFile file(path); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + REQUIRE(file.write(bytes) == bytes.size()); +} + +void writeSnapshot(const QString& path, const QStringList& packageIds) { + ModsConfig config; + config.version = QStringLiteral("1.5"); + for (const QString& id : packageIds) { + config.activeMods.append(CaseInsensitiveStr(id)); + } + REQUIRE( + px::modrim::writeModsConfig(path, config, QFileInfo(path).absolutePath()) + .has_value()); +} + +QListWidget* listOf(RestoreSnapshotDialog& dialog) { + return dialog.findChild(); +} + +QPushButton* buttonLabelled(RestoreSnapshotDialog& dialog, + const QString& text) { + for (QPushButton* button : dialog.findChildren()) { + if (button->text() == text) { + return button; + } + } + return nullptr; +} + +// The details label is the only label carrying markup; the instruction line has +// none. +QString detailsText(RestoreSnapshotDialog& dialog) { + for (QLabel* label : dialog.findChildren()) { + if (label->text().contains(QStringLiteral(""))) { + return label->text(); + } + } + return {}; +} + +} // namespace + +TEST_CASE( + "Restore dialog selects readable snapshots and disables Restore for " + "unreadable ones", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString validSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, + {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corruptSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 2); + + // The first readable snapshot is selected initially. + REQUIRE(dialog.selectedSnapshot() == validSnap); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + QPushButton* cancelBtn = buttonLabelled(dialog, QStringLiteral("Cancel")); + REQUIRE(restoreBtn != nullptr); + REQUIRE(cancelBtn != nullptr); + REQUIRE(restoreBtn->isEnabled()); + REQUIRE(restoreBtn->parentWidget() == &dialog); + + list->setCurrentRow(1); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + REQUIRE_FALSE(restoreBtn->isEnabled()); + + list->setCurrentRow(0); + REQUIRE(dialog.selectedSnapshot() == validSnap); + REQUIRE(restoreBtn->isEnabled()); + + cancelBtn->click(); + REQUIRE(dialog.result() == QDialog::Rejected); + + RestoreSnapshotDialog acceptDialog(QStringList{validSnap}); + QListWidget* acceptList = listOf(acceptDialog); + REQUIRE(acceptList != nullptr); + emit acceptList->itemDoubleClicked(acceptList->item(0)); + REQUIRE(acceptDialog.result() == QDialog::Accepted); + REQUIRE(acceptDialog.selectedSnapshot() == validSnap); +} + +TEST_CASE("Restore dialog Restore button accepts the selected snapshot", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString validSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, {QStringLiteral("mod.foo")}); + + RestoreSnapshotDialog dialog(QStringList{validSnap}); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + REQUIRE(restoreBtn != nullptr); + restoreBtn->click(); + REQUIRE(dialog.result() == QDialog::Accepted); + REQUIRE(dialog.selectedSnapshot() == validSnap); +} + +TEST_CASE( + "Restore dialog double-click on an unreadable snapshot does not accept", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString corruptSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{corruptSnap}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + emit list->itemDoubleClicked(list->item(0)); + REQUIRE(dialog.result() == QDialog::Rejected); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); +} + +TEST_CASE( + "Restore dialog lists snapshot timestamps and mod counts in given order", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString twoMods = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(twoMods, + {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString oneMod = + dir.filePath(QStringLiteral("ModsConfig_20260922_140000.xml")); + writeSnapshot(oneMod, {QStringLiteral("mod.foo")}); + const QString noMods = + dir.filePath(QStringLiteral("ModsConfig_20260922_150000_123456.xml")); + writeSnapshot(noMods, {}); + const QString corrupt = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corrupt, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{twoMods, oneMod, noMods, corrupt}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 4); + + const QString dash = QString::fromUtf8("\u2014"); + REQUIRE(list->item(0)->text() == + QStringLiteral("2026-09-22 12:00:00 UTC ") + dash + + QStringLiteral(" 2 active mods, RimWorld 1.5")); + REQUIRE(list->item(1)->text() == + QStringLiteral("2026-09-22 14:00:00 UTC ") + dash + + QStringLiteral(" 1 active mod, RimWorld 1.5")); + REQUIRE(list->item(2)->text() == + QStringLiteral("2026-09-22 15:00:00 UTC ") + dash + + QStringLiteral(" 0 active mods, RimWorld 1.5")); + REQUIRE(list->item(3)->text() == QStringLiteral("2026-09-22 13:00:00 UTC ") + + dash + + QStringLiteral(" Cannot be read")); + REQUIRE(list->item(0)->toolTip() == + QStringLiteral("ModsConfig_20260922_120000.xml")); +} + +TEST_CASE( + "Restore dialog falls back to file modification time for unnamed or " + "invalid snapshot names", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString unnamed = dir.filePath(QStringLiteral("custom.xml")); + writeSnapshot(unnamed, {QStringLiteral("mod.foo")}); + const QString badDate = + dir.filePath(QStringLiteral("ModsConfig_20261322_120000.xml")); + writeSnapshot(badDate, {QStringLiteral("mod.foo")}); + + RestoreSnapshotDialog dialog(QStringList{unnamed, badDate}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 2); + + const QRegularExpression pattern(QStringLiteral( + R"(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC \x{2014} 1 active mod, RimWorld 1\.5$)")); + REQUIRE(pattern.match(list->item(0)->text()).hasMatch()); + REQUIRE(pattern.match(list->item(1)->text()).hasMatch()); +} + +TEST_CASE("Restore dialog details describe the selected snapshot", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + const QString validSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_120000.xml")); + writeSnapshot(validSnap, + {QStringLiteral("mod.foo"), QStringLiteral("mod.bar")}); + const QString corruptSnap = + dir.filePath(QStringLiteral("ModsConfig_20260922_130000.xml")); + writeText(corruptSnap, "<<>>"); + + RestoreSnapshotDialog dialog(QStringList{validSnap, corruptSnap}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + + REQUIRE( + detailsText(dialog) == + QStringLiteral("File: ModsConfig_20260922_120000.xml
    Active " + "mods: 2 | " + "Game version: 1.5")); + + list->setCurrentRow(1); + REQUIRE(detailsText(dialog) == + QStringLiteral("This saved mod list cannot be read. " + "ModsConfig_20260922_130000.xml " + "will not be restored.")); + + list->setCurrentItem(nullptr); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + REQUIRE(detailsText(dialog).isEmpty()); +} + +TEST_CASE("Restore dialog with no snapshots has nothing to select", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + + RestoreSnapshotDialog dialog(QStringList{}); + QListWidget* list = listOf(dialog); + REQUIRE(list != nullptr); + REQUIRE(list->count() == 0); + REQUIRE_FALSE(dialog.selectedSnapshot().has_value()); + QPushButton* restoreBtn = buttonLabelled(dialog, QStringLiteral("Restore")); + REQUIRE(restoreBtn != nullptr); + REQUIRE_FALSE(restoreBtn->isEnabled()); +} + +TEST_CASE("Confirm restore dialog names the snapshot and defaults to Cancel", + "[ui][restore_snapshot]") { + [[maybe_unused]] auto& app = ensureApp(); + + ConfirmRestoreDialog confirm( + QStringLiteral("ModsConfig_20260922_120000.xml")); + REQUIRE(confirm.windowTitle() == QStringLiteral("Restore Mod List")); + REQUIRE(confirm.text().contains( + QStringLiteral("ModsConfig_20260922_120000.xml"))); + REQUIRE(confirm.informativeText().contains(QStringLiteral("backup"))); + REQUIRE(confirm.icon() == QMessageBox::Question); + REQUIRE(confirm.defaultButton() == confirm.button(QMessageBox::Cancel)); + + auto* restoreBtn = + qobject_cast(confirm.button(QMessageBox::Yes)); + REQUIRE(restoreBtn != nullptr); + REQUIRE(restoreBtn->text() == QStringLiteral("Restore")); + REQUIRE(restoreBtn->objectName() == QStringLiteral("primaryAction")); + auto* cancelBtn = + qobject_cast(confirm.button(QMessageBox::Cancel)); + REQUIRE(cancelBtn != nullptr); + REQUIRE(cancelBtn->text() == QStringLiteral("Cancel")); +} diff --git a/tests/ui/test_settings_panel.cpp b/tests/ui/test_settings_panel.cpp new file mode 100644 index 0000000..c70f8d4 --- /dev/null +++ b/tests/ui/test_settings_panel.cpp @@ -0,0 +1,69 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "test_support.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/panels/settings_panel.hpp" +#include "ui/theme/qml_theme.hpp" +namespace { +void ensureApplication() { + if (QCoreApplication::instance()) return; + static int argc = 1; + static char name[] = "settings_panel_tests"; + static char* argv[] = {name, nullptr}; + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) + qputenv("QT_QPA_PLATFORM", "offscreen"); + static QApplication app(argc, argv); +} +void settle(int milliseconds) { + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + timer.start(milliseconds); + loop.exec(); +} +} // namespace +TEST_CASE("Cancel settings unloads QML without binding errors") { + ensureApplication(); + QWidget host; + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + QList warnings; + QObject::connect( + engine, &QQmlEngine::warnings, &host, + [&](const QList& values) { warnings.append(values); }); + auto context = px::ui_test::makeCore(); + px::modrim::SettingsPanel panel(*context, engine, &host); + panel.resize(760, 640); + panel.show(); + settle(300); + auto* quick = panel.findChild(); + REQUIRE(quick != nullptr); + auto* root = quick->rootObject(); + REQUIRE(root != nullptr); + QQuickItem* cancel = nullptr; + for (auto* item : root->findChildren()) + if (QString::fromLatin1(item->metaObject()->className()) + .startsWith("PxButton") && + item->property("text").toString() == "Cancel") { + cancel = item; + break; + } + REQUIRE(cancel != nullptr); + REQUIRE(QMetaObject::invokeMethod(cancel, "clicked", Qt::DirectConnection)); + settle(20); + CHECK(quick->rootObject() == nullptr); + CHECK_FALSE(panel.isVisible()); + CHECK(warnings.isEmpty()); +} diff --git a/tests/ui/test_sidebar_model.cpp b/tests/ui/test_sidebar_model.cpp new file mode 100644 index 0000000..30b2361 --- /dev/null +++ b/tests/ui/test_sidebar_model.cpp @@ -0,0 +1,44 @@ +#include + +#include + +#include "ui/models/sidebar_model.hpp" +#include "ui/theme/palette.hpp" +TEST_CASE("sidebar entry updates refresh provider icons without resetting") { + using px::modrim::view::SidebarEntry; + px::modrim::SidebarModel model; + model.setEntries( + {{SidebarEntry::Kind::kProvider, "Steam", {"a", "b"}, "steam"}}); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + model.updateEntries( + {{SidebarEntry::Kind::kProvider, "Local", {"a", "b"}, "local"}}); + CHECK(model.data(model.index(0), px::modrim::SidebarModel::kIconRole) + .toString() == "folder"); + CHECK(model.data(model.index(0), px::modrim::SidebarModel::kIconColorRole) + .toString() == px::modrim::paletteValue(u"TEXT_DIM")); + CHECK(model.data(model.index(0), px::modrim::SidebarModel::kCountRole) + .toInt() == 2); + CHECK(reset.isEmpty()); + REQUIRE(changed.size() == 1); + CHECK(changed.first()[2].value>().contains( + px::modrim::SidebarModel::kIconRole)); + CHECK(model.getEntry(0)->providerId == "local"); +} +TEST_CASE( + "sidebar models status sections and resets when its entry shape changes") { + using px::modrim::SidebarModel; + using px::modrim::view::SidebarEntry; + SidebarModel model; + model.setEntries({{SidebarEntry::Kind::kAll, "All", {"a"}, {}}, + {SidebarEntry::Kind::kWithErrors, "Errors", {"a"}, {}}}); + CHECK(model.data(model.index(0), SidebarModel::kSectionRole).toString() == + "Status"); + CHECK(model.data(model.index(1), SidebarModel::kBadgeFgRole).toString() == + px::modrim::paletteValue(u"DANGER")); + CHECK_FALSE(model.getEntry(-1).has_value()); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + model.updateEntries({}); + CHECK(reset.size() == 1); + CHECK(model.rowCount() == 0); +} diff --git a/tests/ui/test_startup_impact_data.cpp b/tests/ui/test_startup_impact_data.cpp new file mode 100644 index 0000000..21b6b69 --- /dev/null +++ b/tests/ui/test_startup_impact_data.cpp @@ -0,0 +1,123 @@ +#include +#include + +#include "ui/panels/startup_impact_data.hpp" +using namespace px::modrim; +using namespace px::modrim::startup_impact; +namespace { +StartupImpactReport report() { + StartupImpactReport result; + result.mods = {{"Small", + QStringLiteral("a.small"), + 1, + {{"LoadingProgress.StartupImpact.ApplyPatches", 1}}}, + {"Big", + QStringLiteral("b.big"), + 4, + {{"LoadingProgress.StartupImpact." + "ModContentPackReloadContentInt.Textures", + 3}, + {"LoadingProgress.StartupImpact.ApplyPatches", 1}}, + {}, + 0.5}, + {QString(kBaseGamePid), + QString(kBaseGamePid), + 10, + {{"LoadingProgress.StartupImpact." + "GlobalTextureAtlasManagerBakeStaticAtlases", + 10}}}}; + return result; +} +QVariantMap build(QStringList active = {}, std::optional selected = {}, + double estimate = 0) { + return buildStartupImpact(report(), active, selected, estimate); +} +} // namespace +TEST_CASE("Startup impact excludes base and ranks mods") { + const auto rows = build().value("mods").toList(); + REQUIRE(rows.size() == 2); + CHECK(rows[0].toMap().value("name").toString() == "Big"); + CHECK(rows[1].toMap().value("name").toString() == "Small"); + CHECK(rows[0].toMap().value("rank").toInt() == 1); + CHECK(rows[1].toMap().value("rank").toInt() == 2); + const auto sum = [](const QVariantMap& row) { + double total = 0; + for (const auto& segment : row.value("segments").toList()) + total += segment.toMap().value("fraction").toDouble(); + return total; + }; + CHECK(sum(rows[0].toMap()) == Catch::Approx(1)); + CHECK(sum(rows[1].toMap()) == Catch::Approx(0.25)); + CHECK(rows[0].toMap().value("dominant").toString() == "Textures & audio"); + CHECK(rows[0].toMap().value("off_thread").toString() == "500 ms"); +} +TEST_CASE("Startup impact normalizes selected and active package IDs") { + const auto data = build({"B.Big_steam"}, QStringLiteral("A.SMALL")); + const auto rows = data.value("mods").toList(); + CHECK(rows[0].toMap().value("active").toBool()); + CHECK_FALSE(rows[0].toMap().value("selected").toBool()); + CHECK_FALSE(rows[1].toMap().value("active").toBool()); + CHECK(rows[1].toMap().value("selected").toBool()); + CHECK(data.value("active_count").toInt() == 1); + CHECK(data.value("selected_key").toString() == "a.small"); +} +TEST_CASE("Startup impact summary and estimates") { + const auto data = build({}, {}, 12); + CHECK(data.value("estimated").toString().isEmpty()); + CHECK(build({"a.small"}, {}, 12).value("estimated").toString() == "12.00 s"); + CHECK(data.value("last_launch").toString() == "15.00 s"); + const auto summary = data.value("summary").toList(); + REQUIRE(summary.size() == 3); + double total = 0; + for (qsizetype i = 0; i < summary.size(); ++i) { + CHECK(summary[i].toMap().value("parallel").toBool() == (i == 2)); + total += summary[i].toMap().value("fraction").toDouble(); + } + CHECK(total == Catch::Approx(1)); + CHECK(summary[0].toMap().value("fraction").toDouble() == + Catch::Approx(10.0 / 15.5)); +} +TEST_CASE("Startup impact phases toggle base contribution") { + const auto data = build({}, QStringLiteral("b.big")); + const auto phases = data.value("phases_with_base").toList(); + REQUIRE(phases.size() == 2); + const auto content = phases[0].toMap(); + CHECK(content.value("label").toString() == "Textures & audio"); + CHECK(phases[1].toMap().value("label").toString() == "Patches"); + CHECK(content.value("value").toString() == "13.00 s"); + CHECK(content.value("share").toString() == "87%"); + const auto contributors = content.value("mods").toList(); + REQUIRE(contributors.size() == 2); + CHECK(contributors[0].toMap().value("name").toString() == "Base game"); + CHECK(contributors[0].toMap().value("base").toBool()); + CHECK(contributors[1].toMap().value("selected").toBool()); + const auto modsOnly = data.value("phases_mods_only").toList(); + REQUIRE(modsOnly.size() == 2); + CHECK(modsOnly[0].toMap().value("value").toString() == "3.00 s"); + CHECK(modsOnly[1].toMap().value("value").toString() == "2.00 s"); + for (const auto& phase : modsOnly) + for (const auto& mod : phase.toMap().value("mods").toList()) + CHECK_FALSE(mod.toMap().value("base").toBool()); +} +TEST_CASE("Startup impact duration formatting") { + CHECK(formatDuration(0.0004) == "0 ms"); + CHECK(formatDuration(0.25) == "250 ms"); + CHECK(formatDuration(59.5) == "59.50 s"); + CHECK(formatDuration(60) == "1m 00s"); + CHECK(formatDuration(268.7) == "4m 29s"); +} +TEST_CASE("Startup impact tooltip combines metrics sharing labels") { + auto data = report(); + data.mods[0].metrics = {{"ApplyPatches|Foo -> a", 1}, + {"ApplyPatches|Foo -> b", 2}}; + const auto rows = buildStartupImpact(data, {}, {}, 0).value("mods").toList(); + const auto tooltip = rows[1] + .toMap() + .value("phases") + .toList()[0] + .toMap() + .value("tooltip") + .toString(); + CHECK(tooltip.count('\n') == 0); + CHECK(tooltip.endsWith("3.00 s")); +} diff --git a/tests/ui/test_steam_workshop.cpp b/tests/ui/test_steam_workshop.cpp new file mode 100644 index 0000000..29609a4 --- /dev/null +++ b/tests/ui/test_steam_workshop.cpp @@ -0,0 +1,185 @@ +#if defined(PX_HAS_STEAM) && defined(PX_HAS_WEBENGINE) + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "core/downloads/steam.hpp" +#include "test_support.hpp" +#include "ui/context.hpp" +#include "ui/plugins/steam_workshop/bridge_obj.hpp" +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/steam_workshop/view.hpp" + +namespace { +using namespace px::modrim; + +struct PluginFixture { + QTemporaryDir dir; + std::unique_ptr core = px::ui_test::makeCore(); + AppContext app{*core}; + SteamWorkshopUiPlugin plugin; + + PluginFixture() { + REQUIRE(dir.isValid()); + core->registerPlugin(std::make_unique(SteamDownloaderDeps{ + .manager = core->downloads(), + .config = core->configService(), + .localModsPath = [] { return QString{}; }, + .installedMods = [this] { return core->allMods().values(); }})); + plugin.setup(app); + } + + QString install(const QString& copy, const QString& publishedId) { + auto mod = std::make_shared(); + REQUIRE(mod->setModPath(dir.filePath(copy + QLatin1Char('/') + publishedId)) + .has_value()); + REQUIRE(mod->publishedFileId() == publishedId); + auto mods = core->allMods(); + const auto uuid = mod->uuid(); + mods.insert(uuid, std::move(mod)); + core->load(std::move(mods), core->activeUuids()); + return uuid; + } +}; +} // namespace + +TEST_CASE("Steam browser accepts only HTTPS Steam Community origins") { + CHECK(SteamWorkshopViewPanel::isSteamOrigin( + QUrl(QStringLiteral("https://steamcommunity.com/workshop/")))); + CHECK(SteamWorkshopViewPanel::isSteamOrigin( + QUrl(QStringLiteral("https://www.steamcommunity.com/")))); + CHECK(SteamWorkshopViewPanel::isSteamOrigin( + QUrl(QStringLiteral("https://HELP.STEAMCOMMUNITY.COM/")))); + for (const auto* text : + {"https://evil.com/", "https://steamcommunity.com.evil.com/", + "https://notsteamcommunity.com/", "http://steamcommunity.com/", + "https://steamcommunity.com@evil.com/", + "https://evil.com/?host=steamcommunity.com", + "https://steampowered.com/", "about:blank"}) { + CAPTURE(text); + CHECK_FALSE( + SteamWorkshopViewPanel::isSteamOrigin(QUrl(QString::fromLatin1(text)))); + } + CHECK_FALSE(SteamWorkshopViewPanel::isSteamOrigin(QUrl{})); +} + +TEST_CASE("Steam bridge rejects calls without a trusted live page") { + SteamWorkshopUiPlugin plugin; + PxModRimBridge bridge(plugin, nullptr); + QSignalSpy replies(&bridge, &PxModRimBridge::resultReady); + REQUIRE(replies.isValid()); + int sidebarChanges = 0; + plugin.sidebarSyncRequested.connect( + [&sidebarChanges](const SidebarSync&) { ++sidebarChanges; }); + + const auto call = [&bridge] { + bridge.call(QStringLiteral("request"), + QStringLiteral("toggle_download_checked"), + QStringLiteral(R"({"mod_id":"1","checked":true})")); + }; + const auto checkDenied = [&] { + const auto previousReplies = replies.size(); + const auto previousChanges = sidebarChanges; + call(); + REQUIRE(replies.size() == previousReplies + 1); + const auto response = + QJsonDocument::fromJson(replies.back()[1].toString().toUtf8()).object(); + CHECK_FALSE(response.value(QStringLiteral("ok")).toBool()); + CHECK_FALSE(response.value(QStringLiteral("error")).toString().isEmpty()); + CHECK(sidebarChanges == previousChanges); + }; + + checkDenied(); + auto page = std::make_unique(); + bridge.setPage(page.get()); + page->setProperty("url", QUrl(QStringLiteral("https://steamcommunity.com/"))); + call(); + REQUIRE(replies.size() == 2); + CHECK(QJsonDocument::fromJson(replies.back()[1].toString().toUtf8()) + .object() + .value(QStringLiteral("ok")) + .toBool()); + CHECK(sidebarChanges == 1); + + for (const auto* url : + {"https://evil.com/", "https://steamcommunity.com.evil.com/", + "http://steamcommunity.com/"}) { + page->setProperty("url", QUrl(QString::fromLatin1(url))); + checkDenied(); + } + page.reset(); + checkDenied(); +} + +TEST_CASE("Steam browser deactivation removes every installed copy at once") { + PluginFixture f; + const auto first = f.install(QStringLiteral("copy-a"), QStringLiteral("1")); + const auto duplicate = + f.install(QStringLiteral("copy-b"), QStringLiteral("1")); + const auto second = f.install(QStringLiteral("copy-a"), QStringLiteral("2")); + const auto other = f.install(QStringLiteral("copy-a"), QStringLiteral("3")); + f.core->setActive({first, duplicate, first, second, other}); + int changes = 0; + f.core->activeStateChanged().connect( + [&changes](const QStringList&) { ++changes; }); + + SECTION("single published id") { + f.plugin.toggleActive(QStringLiteral("1"), false); + CHECK(f.core->activeUuids() == QStringList{second, other}); + } + SECTION("batch published ids") { + f.plugin.batchToggleActive({QStringLiteral("1"), QStringLiteral("2")}, + false); + CHECK(f.core->activeUuids() == QStringList{other}); + } + CHECK(changes == 1); +} + +TEST_CASE("Steam browser activation keeps one chosen copy per published id") { + PluginFixture f; + const auto first = f.install(QStringLiteral("copy-a"), QStringLiteral("1")); + const auto duplicate = + f.install(QStringLiteral("copy-b"), QStringLiteral("1")); + const auto second = f.install(QStringLiteral("copy-a"), QStringLiteral("2")); + + SECTION("single activation chooses only one installed copy") { + f.plugin.toggleActive(QStringLiteral("1"), true); + REQUIRE(f.core->activeUuids().size() == 1); + CHECK((f.core->activeUuids().contains(first) || + f.core->activeUuids().contains(duplicate))); + } + SECTION("single activation retains an already active copy") { + f.core->setActive({duplicate}); + f.plugin.toggleActive(QStringLiteral("1"), true); + CHECK(f.core->activeUuids() == QStringList{duplicate}); + } + SECTION("batch activation retains an active copy and selects missing ids") { + f.core->setActive({duplicate}); + int changes = 0; + f.core->activeStateChanged().connect( + [&changes](const QStringList&) { ++changes; }); + f.plugin.batchToggleActive({QStringLiteral("1"), QStringLiteral("2"), + QStringLiteral("1"), QStringLiteral("missing")}, + true); + CHECK(f.core->activeUuids() == QStringList{duplicate, second}); + CHECK(changes == 1); + } + SECTION("batch activation chooses one copy for each inactive published id") { + f.plugin.batchToggleActive({QStringLiteral("1"), QStringLiteral("2")}, + true); + REQUIRE(f.core->activeUuids().size() == 2); + CHECK(f.core->activeUuids().contains(second)); + CHECK((f.core->activeUuids().contains(first) != + f.core->activeUuids().contains(duplicate))); + } +} + +#endif diff --git a/tests/ui/test_support.hpp b/tests/ui/test_support.hpp new file mode 100644 index 0000000..ca27cdc --- /dev/null +++ b/tests/ui/test_support.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include +#include + +#include "core/config.hpp" +#include "core/context.hpp" + +namespace px::ui_test { + +// CoreContext services read their cache/config files through a ConfigService, +// so tests give every context one rooted in a directory that lives for the +// whole test run. +inline std::unique_ptr makeCore( + px::modrim::AppConfig config = {}) { + static QTemporaryDir configDir; + static px::modrim::ConfigService configService(configDir.path()); + return px::modrim::CoreContext::create(std::move(config), &configService); +} + +} // namespace px::ui_test diff --git a/tests/ui/test_toast_and_progress.cpp b/tests/ui/test_toast_and_progress.cpp new file mode 100644 index 0000000..71b293a --- /dev/null +++ b/tests/ui/test_toast_and_progress.cpp @@ -0,0 +1,91 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/loading.hpp" +#include "ui/components/progress_dialog.hpp" +#include "ui/components/toast.hpp" + +namespace { + +using px::modrim::ProgressDialog; +using px::modrim::Toast; +using px::modrim::ToastLevel; +using px::modrim::ToastManager; + +QApplication& application() { + if (auto* app = qobject_cast(QCoreApplication::instance())) { + return *app; + } + static int argc = 1; + static char name[] = "test_toast_and_progress"; + static char* argv[] = {name, nullptr}; + static QApplication app(argc, argv); + return app; +} + +// Runs the event loop for `ms` so animations and timers can fire. +void waitMs(int ms) { + QEventLoop loop; + QTimer::singleShot(ms, &loop, &QEventLoop::quit); + loop.exec(); +} + +} // namespace + +TEST_CASE("close button removes a persistent toast") { + application(); + QWidget parent; + ToastManager manager(&parent); + manager.showToast(QStringLiteral("stays"), ToastLevel::kInfo, 0); + + auto* toast = manager.findChild(); + REQUIRE(toast != nullptr); + auto* close = toast->findChild(QStringLiteral("toastClose")); + REQUIRE(close != nullptr); + + close->click(); + waitMs(300); + + REQUIRE(manager.layout()->count() == 0); +} + +TEST_CASE("timed toast dismisses itself after its duration") { + application(); + QWidget parent; + ToastManager manager(&parent); + manager.info(QStringLiteral("gone soon"), 50); + REQUIRE(manager.layout()->count() == 1); + + waitMs(600); + + REQUIRE(manager.layout()->count() == 0); +} + +TEST_CASE( + "progress dialog stays visible when a new task starts before it hides") { + application(); + QWidget parent; + px::modrim::LoadingState loading(&parent); + ProgressDialog dialog(loading, &parent); + + { + auto first = loading.task(QStringLiteral("first")); + } + { + auto second = loading.task(QStringLiteral("second")); + waitMs(500); + REQUIRE(dialog.isVisible()); + REQUIRE(dialog.windowTitle() == QStringLiteral("second")); + } + + waitMs(800); + REQUIRE_FALSE(dialog.isVisible()); +} diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp new file mode 100644 index 0000000..2418853 --- /dev/null +++ b/tests/ui/test_unity_rich_text.cpp @@ -0,0 +1,144 @@ +#include +#include +#include + +#include + +#include "ui/components/unity_rich_text.hpp" + +namespace { + +struct Case { + const char* input; + const char* expected; +}; + +// Checks every case and reports each failing input, rather than stopping at the +// first. +void checkCases(std::initializer_list cases) { + for (const Case& c : cases) { + INFO("input: " << c.input); + CHECK(px::modrim::unityRichTextToHtml(QString::fromUtf8(c.input)) == + QString::fromUtf8(c.expected)); + } +} + +} // namespace + +TEST_CASE("unity rich text passes plain text through") { + checkCases({{"Hello world", "Hello world"}}); +} + +TEST_CASE("unity rich text maps bold tags") { + checkCases({ + {"bold", "bold"}, + {"bold", "bold"}, + }); +} + +TEST_CASE("unity rich text maps italic tags") { + checkCases({ + {"italic", "italic"}, + {"italic", "italic"}, + }); +} + +TEST_CASE("unity rich text maps underline to a span") { + checkCases( + {{"underline", + "underline"}}); +} + +TEST_CASE("unity rich text maps strikethrough") { + checkCases({{"strike", "strike"}}); +} + +TEST_CASE("unity rich text maps color names and hex values to font color") { + checkCases({ + {"red", "red"}, + {"red", "red"}, + }); +} + +TEST_CASE("unity rich text maps size units to font-size points") { + checkCases({ + {"big", "big"}, + {"big", "big"}, + {"big", "big"}, + {"relative", + "relative"}, + {"small", + "small"}, + }); +} + +TEST_CASE("unity rich text maps align to a text-align div") { + checkCases({{"center", + "
    center
    "}}); +} + +TEST_CASE("unity rich text maps indent to a margin-left div") { + checkCases({{"indented", + "
    indented
    "}}); +} + +TEST_CASE("unity rich text maps mark to a background-colored mark") { + checkCases({{"marked", + "marked"}}); +} + +TEST_CASE("unity rich text keeps sub and sup") { + checkCases({ + {"sub", "sub"}, + {"sup", "sup"}, + }); +} + +TEST_CASE("unity rich text maps http and https links to anchors") { + checkCases({{"link", + "
    link"}}); +} + +TEST_CASE("unity rich text drops links with unsafe urls and keeps their text") { + for (const char* url : { + "javascript:alert(1)", + "steam://rungameid/294100", + "https:example.com", + }) { + INFO("url: " << url); + const QString input = + QStringLiteral("link").arg(QString::fromUtf8(url)); + CHECK(px::modrim::unityRichTextToHtml(input) == QStringLiteral("link")); + } +} + +TEST_CASE("unity rich text uses the default size for a non-finite size") { + // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to + // infinity. + const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); + const QString html = px::modrim::unityRichTextToHtml( + QStringLiteral("large").arg(huge)); + CHECK(html == + QStringLiteral("large")); +} + +TEST_CASE("unity rich text uses the default indent for a non-finite indent") { + // 10**400 as a decimal literal: a 1 followed by 400 zeros, which parses to + // infinity. + const QString huge = QStringLiteral("1") + QString(400, QLatin1Char('0')); + const QString html = px::modrim::unityRichTextToHtml( + QStringLiteral("wide").arg(huge)); + CHECK(html == QStringLiteral("
    wide
    ")); +} + +TEST_CASE("unity rich text nests formatting tags") { + checkCases({ + {"bold italic", "bold italic"}, + {"green", + "green"}, + }); +} + +TEST_CASE("unity rich text strips unknown tags") { + checkCases({{"bar", "bar"}}); +} diff --git a/tests/ui/test_views.cpp b/tests/ui/test_views.cpp new file mode 100644 index 0000000..f4883d9 --- /dev/null +++ b/tests/ui/test_views.cpp @@ -0,0 +1,55 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "test_support.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/context.hpp" +#include "ui/theme/qml_theme.hpp" + +#ifdef PX_HAS_STEAM +#include "ui/plugins/steam_workshop/view.hpp" + +namespace { +// The embedded browser exists only in WebEngine builds; without it there is no +// "workshopWeb" object. +QObject* webOf(const px::modrim::SteamWorkshopViewPanel& view) { + for (auto* quick : view.findChildren()) { + if (auto* root = quick->rootObject()) { + if (auto* web = root->findChild(QStringLiteral("workshopWeb"))) + return web; + } + } + return nullptr; +} +} // namespace + +TEST_CASE("steam workshop view preloads without error") { + QWidget host; + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + auto core = px::ui_test::makeCore(); + px::modrim::AppContext app(*core); + px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); + view.preload(); +} + +TEST_CASE("steam workshop view has no web view before initialization") { + QWidget host; + auto* engine = px::modrim::createQmlEngine(&host); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + auto core = px::ui_test::makeCore(); + px::modrim::AppContext app(*core); + px::modrim::SteamWorkshopViewPanel view(*core, engine, &host, app); + CHECK(webOf(view) == nullptr); +} +#endif diff --git a/tests/ui/test_workshop_images.cpp b/tests/ui/test_workshop_images.cpp new file mode 100644 index 0000000..6616636 --- /dev/null +++ b/tests/ui/test_workshop_images.cpp @@ -0,0 +1,174 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +const QString kWorkshopDir = QStringLiteral("qrc:/qt/qml/PxModRim"); + +constexpr qint64 kWaitMs = 5000; + +// Loads thumbs.js the way `import "thumbs.js" as Thumbs` does, minus the +// `.pragma library` header, which only QML's loader understands. +class Thumbs { + public: + Thumbs() { + const QString path = QStringLiteral(":/qt/qml/PxModRim/thumbs.js"); + QFile file(path); + REQUIRE(file.open(QIODevice::ReadOnly | QIODevice::Text)); + QString script = QString::fromUtf8(file.readAll()); + const QString pragma = QStringLiteral(".pragma library"); + if (script.startsWith(pragma)) script.remove(0, pragma.size()); + const QJSValue result = m_engine.evaluate(script, path); + REQUIRE(!result.isError()); + m_sized = m_engine.globalObject().property(QStringLiteral("sized")); + REQUIRE(m_sized.isCallable()); + } + + QString sized(const QString& url, int width, int height) { + return m_sized + .call(QJSValueList{QJSValue(url), QJSValue(width), QJSValue(height)}) + .toString(); + } + + private: + QJSEngine m_engine; + QJSValue m_sized; +}; + +// Spins the event loop until `predicate` holds or kWaitMs elapses. +bool waitUntil(const std::function& predicate) { + QElapsedTimer clock; + clock.start(); + while (!predicate()) { + if (clock.hasExpired(kWaitMs)) return false; + QEventLoop loop; + QTimer::singleShot(10, &loop, [&loop] { loop.quit(); }); + loop.exec(); + } + return true; +} + +// Runs the teardown when a REQUIRE/CHECK failure unwinds the test body. +struct Cleanup { + std::function run; + ~Cleanup() { run(); } +}; + +} // namespace + +TEST_CASE("thumbnail leaves other URLs unchanged") { + Thumbs thumbs; + const QStringList urls{ + QStringLiteral("https://example.com/image.png"), + QStringLiteral("https://notsteamstatic.com/image.png"), + QStringLiteral("https://steamusercontent.com.example.com/image.png"), + QStringLiteral("https://user@steamstatic.com/image.png"), + QStringLiteral(""), + }; + for (const QString& url : urls) { + INFO(url.toStdString()); + CHECK(thumbs.sized(url, 256, 128) == url); + } +} + +TEST_CASE("thumbnail replaces resize query before fragment") { + Thumbs thumbs; + for (const char* host : {"images.steamusercontent.com", "steamstatic.com", + "cdn.steamstatic.com"}) { + const QString hostName = QString::fromLatin1(host); + INFO(host); + const QString url = QStringLiteral( + "https://%1/" + "image.png?token=abc&imw=720&imh=360&ima=fit&" + "impolicy=Letterbox#preview") + .arg(hostName); + const QString expected = QStringLiteral( + "https://%1/" + "image.png?token=abc&imw=256&imh=128&ima=fit&" + "impolicy=Letterbox#preview") + .arg(hostName); + const QString resized = thumbs.sized(url, 256, 128); + CHECK(resized == expected); + CHECK(thumbs.sized(resized, 256, 128) == expected); + } +} + +TEST_CASE("hidden thumbnail unloads and can load again") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + + QImage image(64, 32, QImage::Format_RGB32); + image.fill(0xFF00FF00); + const QString imagePath = dir.filePath(QStringLiteral("preview.png")); + REQUIRE(image.save(imagePath)); + + const QString sourcePath = dir.filePath(QStringLiteral("Image.qml")); + { + QFile source(sourcePath); + REQUIRE(source.open(QIODevice::WriteOnly | QIODevice::Text)); + const QString text = + QStringLiteral( + "import QtQuick\n" + "import \"%1\" as Workshop\n" + "Workshop.CatalogImage { previewUrl: \"%2\" }\n") + .arg(kWorkshopDir, QUrl::fromLocalFile(imagePath).toString()); + REQUIRE(source.write(text.toUtf8()) >= 0); + } + + QStringList warnings; + auto owner = std::make_unique(); + auto* engine = new QQmlEngine(owner.get()); + QObject::connect(engine, &QQmlEngine::warnings, owner.get(), + [&warnings](const QList& errors) { + for (const QQmlError& error : errors) + warnings.append(error.toString()); + }); + + auto* widget = new QQuickWidget(engine, owner.get()); + widget->setResizeMode(QQuickWidget::SizeRootObjectToView); + widget->resize(200, 110); + widget->setSource(QUrl::fromLocalFile(sourcePath)); + owner->show(); + widget->show(); + const Cleanup cleanup{[&] { + delete widget; + delete engine; + }}; + + QQuickItem* root = widget->rootObject(); + REQUIRE(root != nullptr); + + // QQuickImageBase::Status: Null = 0, Ready = 1. + constexpr int kNull = 0; + constexpr int kReady = 1; + REQUIRE( + waitUntil([&] { return root->property("status").toInt() == kReady; })); + root->setVisible(false); + REQUIRE(waitUntil([&] { return root->property("status").toInt() == kNull; })); + root->setVisible(true); + widget->resize(300, 150); + REQUIRE( + waitUntil([&] { return root->property("status").toInt() == kReady; })); + CHECK(widget->grabFramebuffer().pixelColor(150, 75).name() == + QStringLiteral("#00ff00")); + INFO(warnings.join(QLatin1Char('\n')).toStdString()); + CHECK(warnings.isEmpty()); +} diff --git a/tests/ui/test_workshop_installed.cpp b/tests/ui/test_workshop_installed.cpp new file mode 100644 index 0000000..f5010b4 --- /dev/null +++ b/tests/ui/test_workshop_installed.cpp @@ -0,0 +1,99 @@ +#include +#include + +#include + +#include "ui/plugins/workshop/installed.hpp" + +using px::modrim::workshop::CatalogMod; +using px::modrim::workshop::InstalledList; +using px::modrim::workshop::kPage; + +namespace { +CatalogMod installedMod(std::string id, std::string title, + std::string author = "Someone", + std::vector tags = {}) { + CatalogMod mod; + mod.id = std::move(id); + mod.title = std::move(title); + mod.author.name = std::move(author); + mod.tags = std::move(tags); + return mod; +} + +QStringList ids(const std::vector& mods) { + QStringList result; + for (const auto& mod : mods) result.append(QString::fromStdString(mod.id)); + return result; +} +} // namespace + +TEST_CASE( + "workshop installed search matches title author id and tags case " + "insensitively") { + InstalledList installed; + REQUIRE_FALSE(installed.loaded()); + REQUIRE(installed.total() == 0); + REQUIRE(installed.visible().empty()); + REQUIRE(installed.nextPage().empty()); + installed.search(""); + installed.replace({installedMod("11", "Harmony", "Brrainz"), + installedMod("12", "HugsLib", "Someone", {"Library"}), + installedMod("999", "Other")}); + REQUIRE(installed.loaded()); + REQUIRE(installed.mods().size() == 3); + const std::vector> cases{ + {"harm", {"11"}}, {"BRRAINZ", {"11"}}, {"library", {"12"}}, + {"999", {"999"}}, {"", {"11", "12", "999"}}, {"nothing", {}}}; + for (const auto& [query, expected] : cases) { + CAPTURE(query.toStdString()); + installed.search(query); + REQUIRE(ids(installed.visible()) == expected); + REQUIRE(installed.total() == expected.size()); + } +} + +TEST_CASE("workshop installed paging reveals one page and search restarts it") { + InstalledList installed; + std::vector mods; + for (int i = 0; i < kPage * 2 + 5; ++i) + mods.push_back(installedMod(std::to_string(i), "Mod")); + installed.replace(mods); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.hasMore()); + const auto second = installed.nextPage(); + REQUIRE(second.size() == kPage); + REQUIRE(second.front().id == std::to_string(kPage)); + REQUIRE(installed.nextPage().size() == 5); + REQUIRE_FALSE(installed.hasMore()); + REQUIRE(installed.nextPage().empty()); + installed.search(""); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.total() == kPage * 2 + 5); + installed.nextPage(); + installed.resetPaging(); + REQUIRE(installed.visible().size() == kPage); + REQUIRE(installed.hasMore()); +} + +TEST_CASE( + "workshop replacing installed mods preserves query and current paging") { + InstalledList installed; + installed.search("mod"); + std::vector mods; + for (int i = 0; i < kPage * 3; ++i) + mods.push_back(installedMod(std::to_string(i), "Mod")); + installed.replace(mods); + installed.nextPage(); + installed.replace(mods); + REQUIRE(installed.visible().size() == kPage * 2); + installed.replace( + {installedMod("match", "Mod"), installedMod("other", "Other")}); + REQUIRE(ids(installed.visible()) == QStringList{"match"}); + REQUIRE(installed.total() == 1); + REQUIRE_FALSE(installed.hasMore()); + REQUIRE(installed.nextPage().empty()); + installed.replace({}); + REQUIRE_FALSE(installed.loaded()); + REQUIRE(installed.visible().empty()); +} diff --git a/tests/ui/test_workshop_models.cpp b/tests/ui/test_workshop_models.cpp new file mode 100644 index 0000000..7dc54ef --- /dev/null +++ b/tests/ui/test_workshop_models.cpp @@ -0,0 +1,283 @@ +#include +#include +#include + +#include + +#include "ui/panels/mod_info_data.hpp" +#include "ui/plugins/workshop/models.hpp" + +using namespace px::modrim::workshop; +using namespace px::modrim::workshop; + +namespace { +CatalogMod mod(std::string id = "1") { + CatalogMod value; + value.id = std::move(id); + value.title = "Mod " + value.id; + value.author = Author{"1", "Author", std::nullopt}; + value.description = "[b]Description[/b]"; + value.workshop_url = + "https://steamcommunity.com/sharedfiles/filedetails/?id=" + value.id; + value.tags = {"Utility"}; + value.supported_versions = {"1.5"}; + value.updated_at = 100; + value.file_size = "1048576"; + return value; +} + +InstallState missing(const CatalogMod&) { return InstallState::kMissing; } +InstallState outdated(const CatalogMod&) { return InstallState::kOutdated; } + +int role(const CatalogListModel& model, const QByteArray& name) { + const auto names = model.roleNames(); + for (auto it = names.cbegin(); it != names.cend(); ++it) + if (it.value() == name) return it.key(); + FAIL("Missing role " << name.constData()); + return -1; +} +} // namespace + +TEST_CASE("workshop model rejects unsafe URLs and preserves web URLs") { + for (const auto* url : + {"javascript:alert(1)", "file:///tmp/test", "data:text/plain,hi", + "//example.com", "https:///path", "https://example.com/\nsecret", + "https://[bad", "https://example.com/a b"}) { + CAPTURE(url); + REQUIRE(safeUrl(QString::fromUtf8(url)).isEmpty()); + } + for (const auto* url : {"https://example.com/path", "http://example.com", + "HTTPS://example.com"}) { + REQUIRE(safeUrl(QString::fromUtf8(url)) == QString::fromUtf8(url)); + } +} + +TEST_CASE("workshop catalog roles paging append deduplication and reset") { + QObject owner; + CatalogListModel model(&owner); + const QList rows{itemRow(mod("1"), outdated, "1.6"), + itemRow(mod("2"), outdated, "1.6"), + itemRow(mod("3"), outdated, "1.6")}; + int notifications = 0; + QObject::connect(&model, &CatalogListModel::changed, + [&] { ++notifications; }); + model.setPage({rows[0], rows[1]}, 10, QStringLiteral("page2")); + REQUIRE(model.hasMore()); + REQUIRE(model.cursor() == std::optional(QStringLiteral("page2"))); + REQUIRE(model.count() == 2); + REQUIRE(model.total() == 10); + QSet names; + for (const auto& name : model.roleNames()) + names.insert(QString::fromUtf8(name)); + REQUIRE(names == QSet(rows[0].keyBegin(), rows[0].keyEnd())); + REQUIRE(role(model, "itemId") == Qt::UserRole + 1); + REQUIRE(role(model, "compatLabel") == Qt::UserRole + 25); + const int idRole = role(model, "itemId"); + REQUIRE(model.data(model.index(0), idRole).toString() == "1"); + model.setPage({rows[1], rows[2], rows[2]}, 10, std::nullopt, true); + REQUIRE(model.count() == 3); + REQUIRE_FALSE(model.hasMore()); + REQUIRE(model.data(model.index(2), idRole).toString() == "3"); + REQUIRE_FALSE(model.data({}, idRole).isValid()); + REQUIRE_FALSE(model.data(model.index(0), Qt::DisplayRole).isValid()); + REQUIRE_FALSE(model.data(model.index(0), Qt::UserRole + 26).isValid()); + REQUIRE(model.rowCount(model.index(0)) == 0); + REQUIRE(model.rowCount() == 3); + model.setPage({}, 0, std::nullopt); + REQUIRE(model.count() == 0); + REQUIRE(model.total() == 0); + REQUIRE(notifications == 3); +} + +TEST_CASE( + "workshop refreshing a page changes only changed rows without resetting") { + CatalogListModel model; + auto first = mod("1"), second = mod("2"), third = mod("3"); + auto show = [&] { + model.setPage( + {itemRow(first, missing, "1.5"), itemRow(second, missing, "1.5"), + itemRow(third, missing, "1.5")}, + 3, std::nullopt); + }; + show(); + int resets = 0; + QList changed; + QObject::connect(&model, &QAbstractItemModel::modelReset, [&] { ++resets; }); + QObject::connect(&model, &QAbstractItemModel::dataChanged, + [&](const QModelIndex& firstIndex, const QModelIndex&) { + changed.append(firstIndex.row()); + }); + first.subscriptions = 500; + second.title = "Renamed"; + show(); + REQUIRE(resets == 0); + REQUIRE(changed == QList{1}); + REQUIRE(model.data(model.index(1), role(model, "title")).toString() == + "Renamed"); + auto reversed = model.rows(); + std::reverse(reversed.begin(), reversed.end()); + model.setPage(reversed, 3, std::nullopt); + REQUIRE(resets == 1); + model.refreshRows([](const QVariantMap& row) { + auto copy = row; + copy["queued"] = true; + return copy; + }); + REQUIRE(model.rows().front().value("queued").toBool()); + REQUIRE(changed.back() == 0); + model.setPage({}, 0, std::nullopt); + changed.clear(); + model.refreshRows([](const QVariantMap& row) { return row; }); + REQUIRE(changed.isEmpty()); +} + +TEST_CASE( + "workshop mod rows preserve state sizes versions descriptions and " + "compatibility") { + auto value = mod(); + auto row = itemRow(value, outdated, "1.6"); + REQUIRE(row.value("actionLabel").toString() == "Update"); + REQUIRE(row.value("stateLabel").toString() == "Update available"); + REQUIRE(row.value("incompatible").toBool()); + REQUIRE(row.value("fileSize").toString() == "1.0 MB"); + REQUIRE(row.value("description").toString().isEmpty()); + REQUIRE( + itemRow(value, missing, "1.5", true).value("description").toString() == + px::modrim::descriptionToHtml(QString::fromStdString(value.description), + "bbcode")); + value.supported_versions = {"1.5", "1.10", "1.2", "1.5", "1.2.1"}; + value.tags = {"Mod", "1.5", "Utility", "1.10", "1.2", "1.2.1"}; + row = itemRow(value, missing, "1.5"); + REQUIRE(row.value("versions").toString() == "1.2, 1.2.1, 1.5, 1.10"); + REQUIRE(row.value("tags").toString() == QStringLiteral("Mod · Utility")); + struct Compatibility { + std::vector versions; + QString game; + bool incompatible; + QString label; + }; + const std::vector compatibilityCases{ + {{"1.5"}, "1.5", false, "Works with 1.5"}, + {{"1.4"}, "1.5", true, "Not for 1.5"}, + {{}, "1.5", false, "Version not stated"}, + {{"1.5"}, "", false, ""}}; + for (const auto& scenario : compatibilityCases) { + value.supported_versions = scenario.versions; + value.incompatible = scenario.incompatible; + REQUIRE(itemRow(value, missing, scenario.game) + .value("compatLabel") + .toString() == scenario.label); + } + value.author = {}; + value.file_size.reset(); + row = itemRow(value, missing, ""); + REQUIRE(row.value("author").toString() == "Unknown author"); + REQUIRE(row.value("fileSize").toString() == "Unknown"); +} + +TEST_CASE("workshop vote approval uses counts and formats thousands") { + struct Case { + std::optional votes; + QString approval, up, down; + }; + const std::vector cases{ + {std::nullopt, "Unrated", "0", "0"}, + {Votes{0, 0, 100}, "Unrated", "0", "0"}, + {Votes{0, 1, {}}, "0%", "0", "1"}, + {Votes{1, 0, {}}, "100%", "1", "0"}, + {Votes{1, 2, {}}, "33%", "1", "2"}, + {Votes{2, 1, {}}, "67%", "2", "1"}, + {Votes{1000, 3000, {}}, "25%", "1,000", "3,000"}}; + for (const auto& scenario : cases) { + auto value = mod(); + value.votes = scenario.votes; + const auto row = itemRow( + value, [](const CatalogMod&) { return InstallState::kInstalled; }, + "1.5"); + REQUIRE(row.value("votes").toString() == scenario.approval); + REQUIRE(row.value("votesUp").toString() == scenario.up); + REQUIRE(row.value("votesDown").toString() == scenario.down); + REQUIRE(row.value("actionLabel").toString() == "Installed"); + } +} + +TEST_CASE( + "workshop summaries strip bbcode media and truncate at a word boundary") { + REQUIRE(summarize("[h1]Title[/h1] [img]https://x.test/a.png[/img] Adds " + "[url=https://x.test]a thing[/url]") == + "Title Adds a thing"); + REQUIRE(summarize("[b]x[/b] kept", "text") == "[b]x[/b] kept"); + const QString summary = summarize(QStringLiteral("word ").repeated(100)); + REQUIRE(summary.endsWith(QStringLiteral("word…"))); + REQUIRE(summary.size() <= 141); +} + +TEST_CASE( + "workshop collection rows select previews filter collage and display " + "server size") { + CatalogCollection value; + value.id = "picked:x"; + value.source = CollectionSource::kPicked; + value.title = "C"; + value.author.name = "Curator"; + value.description = "[b]Description[/b]"; + value.previews = {{PreviewType::kVideo, "https://youtube.test/v"}, + {PreviewType::kImage, "https://images.test/own.png"}}; + value.member_ids = {"10"}; + value.member_count = 1; + value.member_previews = {"https://images.test/m.png", "javascript:alert(1)"}; + const auto neverInstall = [](const CatalogMod&) { + FAIL("Collections must not request install state"); + return InstallState::kMissing; + }; + auto row = itemRow(value, neverInstall, "1.6", true); + REQUIRE(row.value("previewUrl").toString() == "https://images.test/own.png"); + REQUIRE(row.value("collage").toStringList() == + QStringList{"https://images.test/m.png"}); + REQUIRE(row.value("votes").toString() == "Unrated"); + REQUIRE(row.value("votesUp").toString() == "0"); + REQUIRE(row.value("votesDown").toString() == "0"); + REQUIRE_FALSE(row.value("active").toBool()); + REQUIRE(row.value("fileSize").toString() == "Unknown"); + REQUIRE(row.value("sourceLabel").toString() == "PxModRim pick"); + REQUIRE(row.value("actionLabel").toString() == "Download all"); + REQUIRE(row.value("memberCount").toInt() == 1); + REQUIRE(row.value("description").toString() == + px::modrim::descriptionToHtml("[b]Description[/b]", "text")); + value.total_size = "3145728"; + value.preview_url = "https://images.test/explicit.png"; + value.source = CollectionSource::kSteam; + row = itemRow(value, neverInstall, "1.6"); + REQUIRE(row.value("fileSize").toString() == "3.0 MB"); + REQUIRE(row.value("previewUrl").toString() == + "https://images.test/explicit.png"); + REQUIRE(row.value("sourceLabel").toString() == "Steam collection"); +} + +TEST_CASE( + "workshop collection version declarations override conflicting member " + "summary") { + CatalogCollection value; + value.no_common_version = true; + auto row = itemRow(value, missing, "1.6"); + REQUIRE(row.value("compatLabel").toString() == "No common version"); + REQUIRE(row.value("incompatible").toBool()); + value.no_common_version = false; + row = itemRow(value, missing, "1.6"); + REQUIRE(row.value("compatLabel").toString() == "Version not stated"); + REQUIRE_FALSE(row.value("incompatible").toBool()); + value.no_common_version = true; + const std::vector< + std::pair, std::vector>> + cases{ + {{}, {"Mod", "1.6"}}, {{"1.5"}, {"Mod", "1.6"}}, {{"1.6"}, {"Mod"}}}; + for (const auto& [versions, tags] : cases) { + value.supported_versions = versions; + value.tags = tags; + row = itemRow(value, missing, "1.6"); + REQUIRE_FALSE(row.value("incompatible").toBool()); + REQUIRE(row.value("compatLabel").toString() == "Works with 1.6"); + REQUIRE(row.value("versions").toString().contains("1.6")); + REQUIRE(row.value("tags").toString() == "Mod"); + } +} diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp new file mode 100644 index 0000000..7551b9f --- /dev/null +++ b/tests/ui/test_workshop_view.cpp @@ -0,0 +1,547 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "../core/workshop/support.hpp" +#include "core/context.hpp" +#include "core/downloads/downloader.hpp" +#include "core/mod_service.hpp" +#include "test_support.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/context.hpp" +#include "ui/navigation.hpp" +#include "ui/plugins/workshop/plugin.hpp" +#include "ui/plugins/workshop/view.hpp" +#include "ui/theme/qml_theme.hpp" + +namespace workshop_view_test { +using namespace px::modrim; +using namespace px::modrim::workshop; +using namespace px::modrim; +using namespace px::modrim::workshop; +using workshop_test::HttpRequest; +using workshop_test::HttpResponse; +using workshop_test::run; +using workshop_test::wire; + +// NOLINTBEGIN(readability-identifier-naming): fields are JSON keys on the wire +struct Batch { + std::vector items; + std::vector unavailable_ids; +}; +// NOLINTEND(readability-identifier-naming) + +class DownloaderFixture : public Downloader { + public: + explicit DownloaderFixture(DownloadManager& manager) + : Downloader(QStringLiteral("test_steam"), QStringLiteral("Steam"), + manager) {} + bool isDownloading() const override { return busy; } + QSet activeIds() const override { return active; } + bool accepts(const QString&) const override { return true; } + std::optional updatableId(const ListedMod& mod) const override { + return mod.publishedFileId(); + } + std::optional lastSynced(const ListedMod&) const override { + return 50.; + } + QCoro::Task> downloadMods(QStringList ids) override { + calls.append(ids); + co_return DownloadResult{.succeeded = ids, .failed = {}, .changed = {}}; + } + void cancel() override {} + bool busy = false; + QSet active; + QList calls; +}; + +struct PanelFixture { + QWidget owner; + QTemporaryDir dir; + std::unique_ptr core = px::ui_test::makeCore(AppConfig{}); + AppContext app{*core}; + DownloaderFixture downloader{core->downloads()}; + WorkshopCatalog catalog{nullptr, core->downloads(), + [this] { return core->allMods().values(); }}; + CatalogMod mod; + CatalogMod dependency; + CatalogCollection collection; + bool fail = false; + bool complete = false; + std::function onRequest; + workshop_test::FakeCatalog server{ + [this](const HttpRequest& request) { return answer(request); }}; + QQmlEngine* engine = nullptr; + std::unique_ptr view; + + PanelFixture() { + REQUIRE(QApplication::instance()); + REQUIRE(dir.isValid()); + core->downloads().registerDownloader(downloader); + mod.id = "1"; + mod.title = "Untrusted title"; + mod.author.name = "Author"; + mod.description = "[b]Safe description[/b]"; + mod.tags = {"Utility"}; + mod.supported_versions = {"1.5"}; + mod.updated_at = 100; + mod.file_size = "1024"; + mod.dependencies = {"2"}; + mod.workshop_url = + "https://steamcommunity.com/sharedfiles/filedetails/?id=1"; + dependency = mod; + dependency.id = "2"; + dependency.title = "Dependency"; + dependency.dependencies.clear(); + collection.id = "picked:test"; + collection.source = CollectionSource::kPicked; + collection.title = "Test collection"; + collection.author.name = "Curator"; + collection.description = "Plain text"; + collection.supported_versions = {"1.6"}; + collection.member_ids = {"1", "missing"}; + collection.member_count = 2; + catalog.setBaseUrl(server.url()); + engine = createQmlEngine(&owner); + engine->rootContext()->setContextProperty(QStringLiteral("Theme"), + new px::modrim::Theme(engine)); + view = std::make_unique(*core, engine, &owner, app, + catalog); + view->resize(1100, 800); + run(view->load()); + } + ~PanelFixture() { + view.reset(); + run(catalog.shutdown()); + } + HttpResponse answer(const HttpRequest& request) { + if (onRequest) onRequest(request); + if (fail) return {503, R"({"error":{"message":"Catalog unavailable"}})"}; + const auto path = request.path(); + if (path == QStringLiteral("/catalog/discover")) + return {200, wire(Discover{{{mod}, 1, {}}, {{collection}, 1, {}}})}; + if (path == QStringLiteral("/catalog/mods")) { + const bool second = + request.query().hasQueryItem(QStringLiteral("cursor")); + return {200, wire(CatalogPage{ + {second ? dependency : mod}, + 2, + second ? std::nullopt + : std::optional("second")})}; + } + if (path == QStringLiteral("/catalog/collections")) + return {200, wire(CatalogPage{{collection}, 1, {}})}; + if (path == QStringLiteral("/catalog/mods/1")) return {200, wire(mod)}; + if (path == QStringLiteral("/catalog/mods/2")) + return {200, wire(dependency)}; + if (path == QStringLiteral("/catalog/collections/picked/test")) { + auto item = collection; + if (complete) { + item.member_ids = {"1"}; + item.member_count = 1; + } + return {200, wire(CollectionDetail{ + item, + {mod}, + complete ? std::vector{} + : std::vector{"missing"}, + complete})}; + } + if (path == QStringLiteral("/catalog/mods/batch")) + return {200, wire(Batch{{mod}, {}})}; + if (path == QStringLiteral("/catalog/resolve")) { + const bool collectionRequest = + request.body.find("picked:test") != std::string::npos; + const bool incomplete = collectionRequest && !complete; + return {200, wire(ResolvedDownload{ + {"1"}, + {{"1", mod}, {"2", dependency}}, + {"2", "1"}, + incomplete ? std::vector{"missing"} + : std::vector{}, + incomplete ? std::vector{"picked:test"} + : std::vector{}, + !incomplete})}; + } + return {404, R"({"error":{"message":"Not found"}})"}; + } + QString install(const QString& id, + const QString& provider = QStringLiteral("steam")) { + auto installed = std::make_shared(); + REQUIRE( + installed->setModPath(dir.filePath(provider + QLatin1Char('/') + id)) + .has_value()); + installed->providerId = provider; + installed->name = id; + auto mods = core->allMods(); + const auto uuid = installed->uuid(); + mods.insert(uuid, installed); + core->load(std::move(mods), core->activeUuids()); + catalog.invalidateInstalled(); + return uuid; + } + qsizetype requests(const QString& path) const { + return std::ranges::count_if(server.requests, [&path](const auto& request) { + return request.path() == path; + }); + } +}; +} // namespace workshop_view_test + +using namespace workshop_view_test; + +TEST_CASE( + "workshop view loads discovery and paginates filtered catalog results") { + PanelFixture f; + CHECK(f.view->findChild()->status() == QQuickWidget::Ready); + CHECK(f.view->modsModel->count() == 1); + CHECK(f.view->collectionsModel->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->filterItems({}, QStringLiteral("1.6"), QStringLiteral("all"), + QStringLiteral("popular"), {})); + run(f.view->load(true)); + CHECK(f.view->modsModel->count() == 2); + CHECK_FALSE(f.view->modsModel->hasMore()); + CHECK(f.server.requests.back().query().queryItemValue( + QStringLiteral("cursor")) == QStringLiteral("second")); + CHECK(f.server.requests.back().query().queryItemValue( + QStringLiteral("version")) == QStringLiteral("1.6")); + f.fail = true; + run(f.view->filterItems(QStringLiteral("uncached"), QStringLiteral("1.6"), + QStringLiteral("all"), QStringLiteral("popular"), + {})); + CHECK(f.view->error() == QStringLiteral("Catalog unavailable")); + f.fail = false; + run(f.view->retryLoad()); + CHECK(f.view->error().isEmpty()); + CHECK(f.view->modsModel->count() == 1); +} + +TEST_CASE("workshop detail escapes descriptions and lists dependencies") { + PanelFixture f; + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->hasDetail()); + CHECK(f.view->membersModel->count() == 1); + CHECK(f.view->detail() + .value(QStringLiteral("description")) + .toString() + .contains(QStringLiteral("Safe description"))); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->detail() + .value(QStringLiteral("description")) + .toString() + .contains(QStringLiteral("<b>Plain text</b>"))); + CHECK(f.view->detail() + .value(QStringLiteral("warning")) + .toString() + .contains(QStringLiteral("missing"))); +} + +TEST_CASE( + "incomplete workshop downloads require explicit partial approval and never " + "activate") { + PanelFixture f; + const auto uuid = f.install(QStringLiteral("1")); + run(f.view->downloadItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->pendingPlan()); + CHECK(f.view->canDownloadAvailable()); + CHECK(f.downloader.calls.isEmpty()); + f.view->downloadAvailable(); + CHECK_FALSE(f.view->pendingPlan()); + REQUIRE(f.downloader.calls.size() == 1); + CHECK(f.downloader.calls.front() == + QStringList{QStringLiteral("2"), QStringLiteral("1")}); + CHECK_FALSE(f.core->activeUuids().contains(uuid)); + CHECK(f.view->notice().contains(QStringLiteral("Mods are not activated"))); +} + +TEST_CASE("workshop partial plan cancellation clears notice without queuing") { + PanelFixture f; + run(f.view->downloadItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + f.view->dismissPlan(); + CHECK_FALSE(f.view->pendingPlan()); + CHECK(f.view->notice().isEmpty()); + CHECK(f.downloader.calls.isEmpty()); +} + +TEST_CASE("workshop collection detail falls back to member preview collage") { + PanelFixture f; + f.mod.preview_url = "https://example.com/preview.png"; + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("collage")).toStringList() == + QStringList{QStringLiteral("https://example.com/preview.png")}); +} + +TEST_CASE( + "workshop collection listing detects partial and fully installed members") { + PanelFixture f; + f.collection.member_ids = {"1", "2"}; + f.collection.member_count = 2; + f.install(QStringLiteral("1")); + run(f.view->selectTabAsync(QStringLiteral("Collections"))); + auto row = f.view->collectionsModel->rows().front(); + CHECK(row.value(QStringLiteral("actionLabel")) == + QStringLiteral("Complete download")); + CHECK(row.value(QStringLiteral("state")) == QStringLiteral("missing")); + f.install(QStringLiteral("2")); + row = f.view->collectionsModel->rows().front(); + CHECK(row.value(QStringLiteral("actionLabel")) == + QStringLiteral("Installed")); + CHECK(row.value(QStringLiteral("state")) == QStringLiteral("installed")); + f.downloader.active = {QStringLiteral("1")}; + f.core->downloads().busyChanged.notify(true); + row = f.view->collectionsModel->rows().front(); + CHECK(row.value(QStringLiteral("queued")).toBool()); + CHECK(row.value(QStringLiteral("actionLabel")) == + QStringLiteral("Downloading")); +} + +TEST_CASE("disabled workshop catalog does not issue requests") { + PanelFixture f; + f.catalog.setBaseUrl({}); + const auto before = f.server.requests.size(); + run(f.view->load()); + CHECK_FALSE(f.view->configured()); + CHECK_FALSE(f.view->busy()); + CHECK(f.server.requests.size() == before); +} + +TEST_CASE("workshop settings apply catalog URL only on save") { + PanelFixture f; + CatalogSettingsSection section(f.catalog, &f.owner); + REQUIRE(section.set(QStringLiteral("url"), + QStringLiteral(" https://example.com/catalog/// "))); + CHECK(f.catalog.settings().url == f.server.url().toStdString()); + f.view->teardown(); + REQUIRE(section.apply().has_value()); + CHECK(f.catalog.settings().url == "https://example.com/catalog"); +} + +TEST_CASE("installed workshop search is local and cached across revisits") { + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + REQUIRE(f.view->modsModel->count() == 1); + const auto fetched = f.requests(QStringLiteral("/catalog/mods/batch")); + run(f.view->filterItems(QStringLiteral("absent"), {}, QStringLiteral("all"), + QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 0); + run(f.view->filterItems(QStringLiteral("Author"), {}, QStringLiteral("all"), + QStringLiteral("popular"), {})); + CHECK(f.view->modsModel->count() == 1); + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == fetched); +} + +TEST_CASE( + "workshop activation toggles one copy and removes every active duplicate") { + PanelFixture f; + const auto first = f.install(QStringLiteral("1")); + const auto second = + f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); + REQUIRE(f.core->activeUuids().size() == 1); + CHECK((f.core->activeUuids().contains(first) || + f.core->activeUuids().contains(second))); + f.core->setActive({first, second}); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); + CHECK(f.core->activeUuids().isEmpty()); + run(f.view->toggleActivationAsync(QStringLiteral("absent"))); + CHECK(f.core->activeUuids().isEmpty()); +} + +TEST_CASE( + "external workshop activation refreshes listing detail and dependencies") { + PanelFixture f; + const auto first = f.install(QStringLiteral("1")); + const auto second = f.install(QStringLiteral("2")); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + f.core->setActive({first, second}); + CHECK(f.view->detail().value(QStringLiteral("active")).toBool()); + CHECK(f.view->modsModel->rows() + .front() + .value(QStringLiteral("active")) + .toBool()); + CHECK(f.view->membersModel->rows() + .front() + .value(QStringLiteral("active")) + .toBool()); +} + +TEST_CASE("workshop tags accumulate excluding supported version tags") { + PanelFixture f; + f.mod.tags = {"1.5", "Utility", "Animals"}; + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + f.dependency.tags = {"Library"}; + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->tagOptions() == + QStringList{QStringLiteral("All tags"), QStringLiteral("Animals"), + QStringLiteral("Library"), QStringLiteral("Utility")}); +} + +TEST_CASE( + "workshop detail back walks history before restoring listing filters") { + PanelFixture f; + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + run(f.view->filterItems(QStringLiteral("query"), QStringLiteral("1.6"), + QStringLiteral("all"), QStringLiteral("popular"), + {})); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->backLabel() == QStringLiteral("Back to Test collection")); + run(f.view->goBack()); + CHECK(f.view->detail().value(QStringLiteral("itemId")) == + QStringLiteral("picked:test")); + run(f.view->goBack()); + CHECK_FALSE(f.view->hasDetail()); + CHECK(f.view->tab() == QStringLiteral("Mods")); + CHECK(f.view->searchQuery() == QStringLiteral("query")); +} + +TEST_CASE("workshop routes tabs items and settings through app navigator") { + PanelFixture f; + QString route; + f.app.setNavigator([&route](const QString& value) { route = value; }); + f.view->openItem(QStringLiteral("picked:test"), QStringLiteral("collection")); + REQUIRE(parseRoute(route)); + CHECK(parseRoute(route)->path == QStringList{QStringLiteral("collection"), + QStringLiteral("picked:test")}); + run(f.view->followRoute(parseRoute(route)->path)); + CHECK(f.view->hasDetail()); + run(f.view->followRoute({QStringLiteral("mods")})); + CHECK(f.view->tab() == QStringLiteral("Mods")); + CHECK_FALSE(f.view->hasDetail()); + f.view->openSettings(); + REQUIRE(parseRoute(route)); + CHECK(parseRoute(route)->viewId == QStringLiteral("settings")); +} + +TEST_CASE("workshop starred collections stay in favourites until unstarred") { + PanelFixture f; + f.view->toggleFavourite(QStringLiteral("picked:test")); + run(f.view->selectTabAsync(QStringLiteral("Favourites"))); + REQUIRE(f.view->collectionsModel->count() == 1); + CHECK(f.view->collectionsModel->rows() + .front() + .value(QStringLiteral("favourite")) + .toBool()); + run(f.view->filterItems(QStringLiteral("not present"), {}, + QStringLiteral("all"), QStringLiteral("popular"), + {})); + CHECK(f.view->collectionsModel->count() == 0); + run(f.view->filterItems({}, {}, QStringLiteral("all"), + QStringLiteral("popular"), {})); + f.view->toggleFavourite(QStringLiteral("picked:test")); + CHECK(f.view->collectionsModel->count() == 0); +} + +TEST_CASE( + "fully installed workshop pack activates all or only its members and " + "dependencies") { + PanelFixture f; + f.complete = true; + const auto first = f.install(QStringLiteral("1")); + const auto duplicate = + f.install(QStringLiteral("1"), QStringLiteral("steamcmd")); + const auto dependency = f.install(QStringLiteral("2")); + const auto other = f.install(QStringLiteral("3")); + f.core->setActive({duplicate, other}); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + CHECK(f.view->detail().value(QStringLiteral("state")) == + QStringLiteral("installed")); + run(f.view->toggleCollectionAsync()); + CHECK(f.core->activeUuids().contains(duplicate)); + CHECK(f.core->activeUuids().contains(dependency)); + CHECK(f.core->activeUuids().contains(other)); + CHECK_FALSE(f.core->activeUuids().contains(first)); + run(f.view->toggleCollectionAsync(true)); + CHECK_FALSE(f.core->activeUuids().contains(other)); + f.core->setActive({first, duplicate, dependency}); + run(f.view->toggleCollectionAsync()); + CHECK(f.core->activeUuids().isEmpty()); +} + +TEST_CASE( + "partly installed and incomplete workshop collections cannot activate") { + PanelFixture f; + f.install(QStringLiteral("1")); + for (bool complete : {false, true}) { + f.complete = complete; + f.catalog.setBaseUrl(f.server.url() + + (complete ? QStringLiteral("/") : QString{})); + run(f.view->openItemAsync(QStringLiteral("picked:test"), + QStringLiteral("collection"))); + run(f.view->toggleCollectionAsync(true)); + CHECK(f.core->activeUuids().isEmpty()); + } +} + +TEST_CASE( + "workshop installed events preserve open details until back reloads the " + "list") { + PanelFixture f; + f.install(QStringLiteral("1")); + run(f.view->selectTabAsync(QStringLiteral("Installed"))); + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + const auto before = f.requests(QStringLiteral("/catalog/mods/batch")); + f.install(QStringLiteral("2")); + CHECK(f.view->hasDetail()); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before); + run(f.view->goBack()); + CHECK_FALSE(f.view->hasDetail()); + CHECK(f.requests(QStringLiteral("/catalog/mods/batch")) == before + 1); +} + +TEST_CASE( + "workshop teardown disconnects events and blocks late fetch and plan " + "completion") { + PanelFixture f; + const auto uuid = f.install(QStringLiteral("1")); + f.onRequest = [&f](const HttpRequest& request) { + if (request.path() == QStringLiteral("/catalog/mods")) f.view->teardown(); + }; + run(f.view->selectTabAsync(QStringLiteral("Mods"))); + f.view->teardown(); + run(f.view->toggleActivationAsync(QStringLiteral("1"))); + CHECK_FALSE(f.core->activeUuids().contains(uuid)); + f.catalog.queueChanged.notify({}); + f.catalog.installedChanged.notify({}); + f.core->downloads().busyChanged.notify(false); + CHECK(f.view->modsModel->count() == 1); +} + +TEST_CASE("workshop discards download plan completing after view destruction") { + PanelFixture f; + f.onRequest = [&f](const HttpRequest& request) { + if (request.path() == QStringLiteral("/catalog/resolve")) f.view.reset(); + }; + run(f.view->downloadItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK_FALSE(f.view); + CHECK(f.downloader.calls.isEmpty()); +} + +TEST_CASE( + "workshop detail metadata retains all tags and numerically sorted " + "versions") { + PanelFixture f; + f.mod.tags = {"1.6", "Utility", "Animals"}; + f.mod.supported_versions = {"1.10", "1.6", "1.5"}; + run(f.view->openItemAsync(QStringLiteral("1"), QStringLiteral("mod"))); + CHECK(f.view->detail().value(QStringLiteral("versions")) == + QStringLiteral("1.5, 1.6, 1.10")); + CHECK(f.view->detail().value(QStringLiteral("tags")) == + QStringLiteral("Utility · Animals")); +} diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 6496784..0000000 --- a/uv.lock +++ /dev/null @@ -1,543 +0,0 @@ -version = 1 -revision = 3 -requires-python = "==3.12.*" -resolution-markers = [ - "sys_platform == 'linux'", - "sys_platform == 'win32'", - "sys_platform == 'darwin'", -] -supported-markers = [ - "sys_platform == 'linux'", - "sys_platform == 'win32'", - "sys_platform == 'darwin'", -] - -[[package]] -name = "aiosqlite" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, -] - -[[package]] -name = "anyio" -version = "4.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, -] - -[[package]] -name = "anytree" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/a8/eb55fab589c56f9b6be2b3fd6997aa04bb6f3da93b01154ce6fc8e799db2/anytree-2.13.0.tar.gz", hash = "sha256:c9d3aa6825fdd06af7ebb05b4ef291d2db63e62bb1f9b7d9b71354be9d362714", size = 48389, upload-time = "2025-04-08T21:06:30.662Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/98/f6aa7fe0783e42be3093d8ef1b0ecdc22c34c0d69640dfb37f56925cb141/anytree-2.13.0-py3-none-any.whl", hash = "sha256:4cbcf10df36b1f1cba131b7e487ff3edafc9d6e932a3c70071b5b768bab901ff", size = 45077, upload-time = "2025-04-08T21:06:29.494Z" }, -] - -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "loguru" -version = "0.7.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "win32-setctime", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, -] - -[[package]] -name = "lxml" -version = "6.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/ad/28ecd7cb894d172f3c9c80a075eeeb2017ac62e3632cee05a5f9493547eb/lxml-6.1.3.tar.gz", hash = "sha256:45222d94ddd511536f3b2f7d9deae3b2339b4ce0f075f1ca25703b07cad9dd21", size = 4211198, upload-time = "2026-09-02T14:48:02.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc", size = 8602094, upload-time = "2026-09-02T14:47:26.054Z" }, - { url = "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d", size = 4638308, upload-time = "2026-09-02T14:47:29.587Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5", size = 4939696, upload-time = "2026-09-02T14:47:32.997Z" }, - { url = "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11", size = 5105247, upload-time = "2026-09-02T14:47:36.734Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a0/a1f7f1313795bfec67b77f01ef3b1128d49f2d7f66a8413fa55d47f4e25f/lxml-6.1.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b37772102d44bb6628186accca3a121b1fa3a6b3d97518a8c29a5229ca4c0d0a", size = 5011915, upload-time = "2026-09-02T14:47:39.846Z" }, - { url = "https://files.pythonhosted.org/packages/b9/78/840e7e3f1d0cc7a5cfac5d8505b97e25b6427fd774ac4bae672aaebfb4b5/lxml-6.1.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddcf547bea2aee967d6a77779376a45e77e610e8465147a1f3d7e20d539d6e32", size = 5638175, upload-time = "2026-09-02T14:47:43.644Z" }, - { url = "https://files.pythonhosted.org/packages/0a/20/e022dbc6b4753a9bc9fc5fb28a27163430c1731b9913997f6544c1b2518c/lxml-6.1.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:909f4e927bb051f7740d6367285fc60cdcfdaf0258c2dba4ff5ba7eadadc250c", size = 5244675, upload-time = "2026-09-02T14:47:47.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/83/82cde81d2b5eb38d1539fdfdf318abdd014a7e604f4df01c9cd3deb18f2a/lxml-6.1.3-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:a5c18810318303ce9afb3f95e2ddb54834f96fa699a8600433fd5a93dcf44c56", size = 5358205, upload-time = "2026-09-02T14:47:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/a1/f3b057371c8cb29f2a9c9c44ea320592446e40b74a4b0af68c3d8e65bc73/lxml-6.1.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:3e42265103fb385d8642a78672edf376c6f7e1d3598a7a4f9cb1278f2f6b5f6f", size = 4704495, upload-time = "2026-09-02T14:47:53.251Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a4/230eb28be5d412152ffc3c679b51fe1aeede5a53f3a8eb6e9748f2f4754f/lxml-6.1.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21402998e4b78e7cce237d2788841aaa21ac9a4d1574d04dc2d12ee41ae807b5", size = 5255117, upload-time = "2026-09-02T14:47:55.963Z" }, - { url = "https://files.pythonhosted.org/packages/a3/18/1969f56763af24ce42ea156007b0b2d73fddea552e283b2010416394f0f4/lxml-6.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38fc4e4e4e084e0bd491949482527d406788045c546d4f8789e93fc527b91385", size = 5054424, upload-time = "2026-09-02T14:47:58.131Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/2a90acc1f6fabaa3a8db9340437822bd8d041b205d626a4b3e8621aaa390/lxml-6.1.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5609efdb0d3c95499c00046bc53648b3482ec2175b5503d6e611b3f0555dc71d", size = 4785572, upload-time = "2026-09-02T14:48:01.029Z" }, - { url = "https://files.pythonhosted.org/packages/a5/1e/b90e845b1dcd0f2f3f26b98283d857f25909223aacd265eee032c34ab8b1/lxml-6.1.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:97ce49699d87ebf8aad631b55d65b33219a4f1bfefbbf5bff19dc9af160aeaf9", size = 5656516, upload-time = "2026-09-02T14:48:03.419Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ab/0a1b802c57f3fba5c4efd77d5c6b78adaa8f7b681f0c90456b140fe8bf6c/lxml-6.1.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:48542c9acba9ff9450bd18d871d2c2c8787fdb283572b623d206f1b927cd7d9e", size = 5245982, upload-time = "2026-09-02T14:48:06.109Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/2c016fbceb3778137459292538d9dfa7e3ad9070fe409c15254ddd90d2cc/lxml-6.1.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c55e71a9b1db1f107efb60da49c093689b74c5c31a708e5379e2fd9439d4fbb5", size = 5267340, upload-time = "2026-09-02T14:48:08.374Z" }, - { url = "https://files.pythonhosted.org/packages/9c/b1/736d18fd6f0835761923b7bac1f0c27d60c1200384e9093f05d8c5100525/lxml-6.1.3-cp312-cp312-win32.whl", hash = "sha256:b3ff39654f0ce6ebd4db154211136dbe7e8157bcc3bed2344c87f32c7c6ecb6c", size = 3602606, upload-time = "2026-09-02T14:48:10.384Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c", size = 4005999, upload-time = "2026-09-02T14:48:12.51Z" }, - { url = "https://files.pythonhosted.org/packages/e4/1b/7bcebb7b6332cb3ae85e9c13b139adb6f23f75c71d84041c56a5005d9a29/lxml-6.1.3-cp312-cp312-win_arm64.whl", hash = "sha256:1aeca87830c4fe649dcf93fe2b059525b71c72587f21be4ae4af7103082a79fa", size = 3666631, upload-time = "2026-09-02T14:48:14.567Z" }, -] - -[[package]] -name = "msgspec" -version = "0.22.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/e6/6dcf9306ff3c5e486578f3bf29ed11dfbdbbc2a8bf0caf7e07d392887fda/msgspec-0.22.0.tar.gz", hash = "sha256:0a13624a4969159fe35d8c2a3d377b2b61bbd8585e327440d5e52725affcce38", size = 343188, upload-time = "2026-09-29T14:14:11.422Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/87/3e017dca361d09ed1cd09dc981a6df21b32e830fbec3470f7486d38b6be5/msgspec-0.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab1e9e7531e353653b906cdd12a0220cc288a1e8e3436aabc65f4508d91b14d9", size = 201301, upload-time = "2026-09-29T14:12:38.048Z" }, - { url = "https://files.pythonhosted.org/packages/fb/02/109165edaafb895668d87177972a32ade9126a54f3736123d8e44be9096d/msgspec-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b60b43425a47eb9cfe987f6874e354ca7c760e58e295b4e2273ff03574df28a1", size = 193044, upload-time = "2026-09-29T14:12:39.46Z" }, - { url = "https://files.pythonhosted.org/packages/54/a5/65de05f8804492f76ea121b21a125cdf1d97ec461c677bfa0ba354d6fbdd/msgspec-0.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5a169b5b03f0f2c7a296c002647db1dab75d2cd501bca34e32b71cab0261b56", size = 224035, upload-time = "2026-09-29T14:12:40.876Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cc/aa1a47f8c92280d37498a5ea56a2a36606d034383e3e6472d64cbb56cf85/msgspec-0.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c401861c5bb3a57f7d6423ea7ed4352cd57aa3f04f4fbe9f3e3e4564a10f08", size = 230377, upload-time = "2026-09-29T14:12:42.796Z" }, - { url = "https://files.pythonhosted.org/packages/61/50/f8bcdb3d613a4a4b92704297a12eba5c985cf572a64ee1a004d265759c69/msgspec-0.22.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08826f5e5b0fa2f7a88592c396a243cfcc63d37e19f9d4fbe3b3f1be2fbdc404", size = 237390, upload-time = "2026-09-29T14:12:44.282Z" }, - { url = "https://files.pythonhosted.org/packages/cf/8a/473fa423f8fdd1b810b8652594323d7301df6920b62844d860daa0feff34/msgspec-0.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21460f54cee9208239b1a8421fdf25bffc77293e1daba88f585711ad839b9758", size = 227733, upload-time = "2026-09-29T14:12:45.839Z" }, - { url = "https://files.pythonhosted.org/packages/03/1d/272ce23adae6c71b3f763aed3ee6e115cccc56124ed8ee0e3e3d2681e2c8/msgspec-0.22.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cfc3d9557de9c806318725b702f3e664db33167bb42892079b693c69893fd33b", size = 236783, upload-time = "2026-09-29T14:12:47.234Z" }, - { url = "https://files.pythonhosted.org/packages/f6/26/29e0b9a8605c8819a3c718158e345a616ac42c092dd7d7ab248c2f2b0a72/msgspec-0.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b25dcbc108783cb72503ed705b9fbb8c3cb02ee5801923f44b5f038c91cc365", size = 232728, upload-time = "2026-09-29T14:12:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a6/99597c281d716da6c662b48dcc3f734669f716b41d5df2af367dac9e7c21/msgspec-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:6ad64f5c260866b0d543f89f50cee43628989c1433c5de7ce820281fa28a2611", size = 192885, upload-time = "2026-09-29T14:12:50.274Z" }, - { url = "https://files.pythonhosted.org/packages/46/80/85fff923d448b886ec3a85900c578d9367f08dad54fe48879495b4c6d055/msgspec-0.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:0922714feff5300aacd8ecd65fa828317ce4bf5212b3139258c0bfc0253cd80e", size = 191223, upload-time = "2026-09-29T14:12:51.699Z" }, -] - -[[package]] -name = "nodeenv" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, -] - -[[package]] -name = "nuitka" -version = "4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/ba7cb858da0c98c24f85c54454a5ea18f2b784f658a91531fa051b2f2ee9/nuitka-4.3.tar.gz", hash = "sha256:8b102c6bf30d9504e82e69674d396972729092b25cefa680e8fd05678fdfa30b", size = 4766934, upload-time = "2026-10-10T11:13:15.577Z" } - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "psutil" -version = "7.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, -] - -[[package]] -name = "pxmodrim" -version = "0.3.1" -source = { editable = "." } -dependencies = [ - { name = "aiosqlite" }, - { name = "httpx" }, - { name = "loguru" }, - { name = "lxml" }, - { name = "msgspec" }, - { name = "psutil" }, - { name = "pyside6" }, - { name = "qasync" }, - { name = "toposort" }, - { name = "ttimer" }, -] - -[package.optional-dependencies] -steam = [ - { name = "pxsteamdl" }, -] - -[package.dev-dependencies] -dev = [ - { name = "nuitka" }, - { name = "pxsteamdl" }, - { name = "pydeps" }, - { name = "pyright" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-qt" }, - { name = "pytest-xvfb" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiosqlite", specifier = ">=0.22.1" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "loguru", specifier = ">=0.7.3" }, - { name = "lxml", specifier = ">=6.1.1" }, - { name = "msgspec", specifier = ">=0.21.1" }, - { name = "psutil", specifier = ">=7.2.2" }, - { name = "pxsteamdl", marker = "extra == 'steam'", git = "https://github.com/PyXiion/PxSteamDL" }, - { name = "pyside6", specifier = ">=6.11.1" }, - { name = "qasync", specifier = ">=0.28.0" }, - { name = "toposort", specifier = ">=1.10" }, - { name = "ttimer", specifier = ">=0.0.2" }, -] -provides-extras = ["steam"] - -[package.metadata.requires-dev] -dev = [ - { name = "nuitka", specifier = ">=2.6.7" }, - { name = "pxsteamdl", git = "https://github.com/PyXiion/PxSteamDL" }, - { name = "pydeps", specifier = ">=3.0.7" }, - { name = "pyright", specifier = ">=1.1.411" }, - { name = "pytest", specifier = ">=9.1.1" }, - { name = "pytest-asyncio", specifier = ">=1.4.0" }, - { name = "pytest-qt", specifier = ">=4.5.0" }, - { name = "pytest-xvfb", specifier = ">=3.1.1" }, - { name = "ruff", specifier = ">=0.16.0" }, -] - -[[package]] -name = "pxsteamdl" -version = "0.3.0" -source = { git = "https://github.com/PyXiion/PxSteamDL#d486d6b2125b8354cf4382020afd7c0774b678cc" } - -[[package]] -name = "pydeps" -version = "3.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "stdlib-list" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/7b/4c40de7ca4c27395adc772bec625674d874f6983657fd8763fb36bee9444/pydeps-3.0.8.tar.gz", hash = "sha256:e54b19d22c8f94b4787bc0bbb3bbe4afc074e32ff9e7868d7750340faec9f8ec", size = 59676, upload-time = "2026-09-07T13:58:56.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/5b/84b74df24b6c958dce0b8d99dc3f5b510f04828ade387ef4ae4a846f2b0a/pydeps-3.0.8-py3-none-any.whl", hash = "sha256:e7d8479292d427c87f7a05e96601c1495ca4147f5e160c48254197f4fc68601f", size = 50215, upload-time = "2026-09-07T13:58:55.576Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyright" -version = "1.1.414" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nodeenv" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, -] - -[[package]] -name = "pyside6" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-addons" }, - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/b5/99ff75f604d69eda1c5d1fe85cc43fb697d95eabc30ba86af099c2ddcd96/pyside6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:13a3c79816879d9743672a669f1988f74459a78f89a97a2624fae4fe059ffa3a", size = 571875, upload-time = "2026-08-18T06:37:27.794Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fe/968844def58150a41568b505edca0d68df6901b20ea4222ccb0c069cc703/pyside6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc6d03990489a5085842770718392ef5de36352d8f608d9a9fe03e60af4d7b66", size = 572120, upload-time = "2026-08-18T06:37:30.298Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/f4ecc6defaec645e5ac3ca2f99e3da286a28d1c647c2d10b584e0392b94c/pyside6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:57fe867a6c93821a085d74e8a26f863fc2363516848181c466032595b33b1729", size = 572121, upload-time = "2026-08-18T06:37:32.383Z" }, - { url = "https://files.pythonhosted.org/packages/94/fe/4aea9d30300fff4cda9bb729c1363b9e1095a8544e0d6b57065465e16599/pyside6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:3201d67e3c10be2eaedd3910ff0f02351eca7e88c95a291cde5e7f2f55ef207f", size = 578382, upload-time = "2026-08-18T06:37:34.385Z" }, - { url = "https://files.pythonhosted.org/packages/26/b0/8e2fb01e9ee15ad29fe10d0dd2d37f503e4aa3714c9fbb07aca380618de7/pyside6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:0444ac71d0791a19bded35f6d9a94515941f23a6eb7098ca45a1b64a338be697", size = 561768, upload-time = "2026-08-18T06:37:36.437Z" }, -] - -[[package]] -name = "pyside6-addons" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyside6-essentials" }, - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/91/339fe9f7c94163815004ccd6b5c58b926b2dfb4e51c3b5563d412eab151f/pyside6_addons-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:354c574a839f7d0751960ba03f33bec87c95e0f2796dca3287f54d03abe97dac", size = 332047075, upload-time = "2026-08-18T06:35:33.037Z" }, - { url = "https://files.pythonhosted.org/packages/28/cd/ce470e20f68901f4dfa5f742d6a3a58ba865684a232e4b87d5a1a2179cc0/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a8b00956925fbdeffc7052cf933506391289f35ac4f3f71a0a167ec195cd47b4", size = 175062576, upload-time = "2026-08-18T06:35:45.583Z" }, - { url = "https://files.pythonhosted.org/packages/11/bd/f725a8984703612c72cd17d0871d047132bacaccc13fc0d288a5e8a37dad/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a2ca3c73e5f060d4aca49451abd9fba68dd0e1c66ff40cea3a1fe0415def579a", size = 170827254, upload-time = "2026-08-18T06:35:56.675Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c1/08bf6594101b42fc88834009f1dac59a983c5b6805c62c5081f077d09b4a/pyside6_addons-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:f449ea4431da20e7b86752cca8d166f93434516fe417f981c27e5f8e1b554407", size = 168208836, upload-time = "2026-08-18T06:36:07.295Z" }, - { url = "https://files.pythonhosted.org/packages/26/60/e4b7f09343e9f29e565f488fb44c4f1fe260db481ba2c08ad8a1a7d48216/pyside6_addons-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:d5606af369484b862b4d5741cfc179e19d2b8819c3e04d210b8188e0c9f85988", size = 35807245, upload-time = "2026-08-18T06:36:12.485Z" }, -] - -[[package]] -name = "pyside6-essentials" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "shiboken6" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/16/0b7ecf89ebada82ed0430be33809ff325761ece83104f8635b1bd101fcd0/pyside6_essentials-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:77795c145202e65a78d88f7cd409d186e3ba23d159bdb3ba2dcd159ae5e5f0d9", size = 110800753, upload-time = "2026-08-18T06:36:33.674Z" }, - { url = "https://files.pythonhosted.org/packages/0e/1a/84a84bbc75835ee88abdcb66eb875b8e1a21834af9b833bc1893f2c1bfaf/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:aaf9f25f0f324874085fa5b26a610318db8a8e243cf85bb3e5400595191c7778", size = 80108947, upload-time = "2026-08-18T06:36:40.637Z" }, - { url = "https://files.pythonhosted.org/packages/60/4c/d92b08a2151d095b9a3725fa0e71b215ec686d40bbd5d8b702d21a9b5eaa/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d3ec6e1885c46e57f16364f38ea8040ffc5dc0b18341058f608fede3ba930567", size = 79575497, upload-time = "2026-08-18T06:36:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/dc/09/d2662684aee4d3f027ff9b1e965f22301bc28ffc5b7f167658508e4475dc/pyside6_essentials-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:c8a29def77032773a30879f7f24415b5395ad08592d147c170824ef4c735dfc1", size = 76913043, upload-time = "2026-08-18T06:36:54.038Z" }, - { url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "pytest-qt" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pluggy" }, - { name = "pytest" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, -] - -[[package]] -name = "pytest-xvfb" -version = "3.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "pyvirtualdisplay" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/ce/b3444c98eab123b7348d55d4705c2f1095a6bdf8dae85bc6a34ab5222845/pytest_xvfb-3.1.1.tar.gz", hash = "sha256:90593634427d974b272e845793e4533f5b827c9d84c051879013624504175e44", size = 9022, upload-time = "2025-03-12T12:35:07.533Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/6e/859b1e496671fb236153c2caed519301c53f7cf3dfcaac809caae695b5a4/pytest_xvfb-3.1.1-py3-none-any.whl", hash = "sha256:3270f91417cfa0652b7c5a90e93a676e3a80bec12ccbd20cae3b42511e4aba98", size = 5861, upload-time = "2025-03-12T12:35:06.028Z" }, -] - -[[package]] -name = "pyvirtualdisplay" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/9f/23e5a82987c26d225139948a224a93318d7a7c8b166d4dbe4de7426dc4e4/PyVirtualDisplay-3.0.tar.gz", hash = "sha256:09755bc3ceb6eb725fb07eca5425f43f2358d3bf08e00d2a9b792a1aedd16159", size = 18560, upload-time = "2022-02-13T07:57:05.783Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/eb/c3b8deb661cb3846db63288c99bbb39f217b7807fc8acb2fd058db41e2e6/PyVirtualDisplay-3.0-py3-none-any.whl", hash = "sha256:40d4b8dfe4b8de8552e28eb367647f311f88a130bf837fe910e7f180d5477f0e", size = 15258, upload-time = "2022-02-13T07:57:04.051Z" }, -] - -[[package]] -name = "qasync" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/b2/5be08597dbbf331edb69478eae2f8dd511834cebf56a183b442e7437f8e0/qasync-0.28.0.tar.gz", hash = "sha256:6f7f1f18971f59cb259b107218269ba56e3ad475ec456e54714b426a6e30b71d", size = 14010, upload-time = "2025-08-28T01:31:36.785Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/84/0ce4cd946f6e958428c87d5accac35df70f81607e45ba4919947d0762d63/qasync-0.28.0-py3-none-any.whl", hash = "sha256:21faba8d047c717008378f5ac29ea58c32a8128528629e4afd57c59b768dba0f", size = 16188, upload-time = "2025-08-28T01:31:35.591Z" }, -] - -[[package]] -name = "ruff" -version = "0.16.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, - { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, - { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, - { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, - { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, - { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, - { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, - { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, - { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, -] - -[[package]] -name = "shiboken6" -version = "6.11.2" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/44/11bf71c36e71936ab4e923e5dd23599dab527c4488b01860b0f689ce9947/shiboken6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:53659683b1f7a08e9f87eff9b1065f1ceb7110cd7a4bc09fdf5efe43d286604d", size = 476674, upload-time = "2026-08-18T06:37:11.526Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9a/f41bea4316d0c1bec4800842dcb4bd8fd3e29d6a79c496bc32e1aec42583/shiboken6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7a7a0a72a9ed26c9bf77d42246b1c736486befb8f31aa2fb29957ea4cdd1c1c2", size = 272435, upload-time = "2026-08-18T06:37:13.397Z" }, - { url = "https://files.pythonhosted.org/packages/56/05/79ed0697515d4fc7435459e4ed4c4a3b5621a55b7c91d0f3f2cc3d617aa0/shiboken6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4bbbd6fa4d7cff5ec5e12bc4c10e1d845fa30c89457ecb13cc64f7bddb77f6f9", size = 270668, upload-time = "2026-08-18T06:37:15.025Z" }, - { url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" }, - { url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" }, -] - -[[package]] -name = "stdlib-list" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/25/f1540879c8815387980e56f973e54605bd924612399ace31487f7444171c/stdlib_list-0.12.0.tar.gz", hash = "sha256:517824f27ee89e591d8ae7c1dd9ff34f672eae50ee886ea31bb8816d77535675", size = 60923, upload-time = "2025-10-24T19:21:22.849Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/3d/2970b27a11ae17fb2d353e7a179763a2fe6f37d6d2a9f4d40104a2f132e9/stdlib_list-0.12.0-py3-none-any.whl", hash = "sha256:df2d11e97f53812a1756fb5510393a11e3b389ebd9239dc831c7f349957f62f2", size = 87615, upload-time = "2025-10-24T19:21:20.619Z" }, -] - -[[package]] -name = "tabulate" -version = "0.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, -] - -[[package]] -name = "toposort" -version = "1.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/69/19/8e955d90985ecbd3b9adb2a759753a6840da2dff3c569d412b2c9217678b/toposort-1.10.tar.gz", hash = "sha256:bfbb479c53d0a696ea7402601f4e693c97b0367837c8898bc6471adfca37a6bd", size = 11132, upload-time = "2023-02-27T13:59:51.834Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/17/57b444fd314d5e1593350b9a31d000e7411ba8e17ce12dc7ad54ca76b810/toposort-1.10-py3-none-any.whl", hash = "sha256:cbdbc0d0bee4d2695ab2ceec97fe0679e9c10eab4b2a87a9372b929e70563a87", size = 8500, upload-time = "2023-02-25T20:07:06.538Z" }, -] - -[[package]] -name = "ttimer" -version = "0.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anytree" }, - { name = "tabulate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d0/39/5ddf98e1fe3ceb46a6c252c1372e137464623ef6515b5a98161a87d5690b/ttimer-0.0.2.tar.gz", hash = "sha256:eb2b68279b7b616b513cdf1dde8c9949c8dc7c322b105262133fdff45410adca", size = 9584, upload-time = "2022-03-22T11:54:56.562Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/4d/b294f1ea65174550e67d49cb37111dd06af0e390cebb8ed0af1694924c5c/ttimer-0.0.2-py3-none-any.whl", hash = "sha256:de02458529a407ea04304ecb232586dedd2f7d894713ba53b9e00a5397cc3587", size = 8098, upload-time = "2022-03-22T11:54:55.487Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "win32-setctime" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, -]