diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..2f8df338 --- /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 83409cf5..9b65be1c 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 b182c5f2..fba1793e 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 700f0be4..3d702a64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,115 +1,159 @@ # 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. +- Interface scale is saved in `ui_prefs.json` as a percentage (100–250). Use `--ui-scale 100` (or `--ui-scale=100`) to recover from an unusable saved scale. `PX_UI_SCALE=150` also overrides the saved scale for one run. Precedence: existing `QT_SCALE_FACTOR` > `--ui-scale` > `PX_UI_SCALE` > saved preference. Invalid CLI/environment percentages are ignored with a warning; valid integers are clamped to 100–250 and never persisted. Qt arguments and unknown flags are left untouched. +- Settings shows the effective scale override and its source. Only saved-scale changes offer **Restart now**; restarting preserves original arguments, follows normal unsaved-mod confirmation and plugin shutdown, and removes only the app-generated `QT_SCALE_FACTOR` from the child environment. Inside an AppImage (`APPIMAGE` is an existing file and the executable lies under `APPDIR`) it relaunches the AppImage, because the runtime removes the mounted executable when the app exits. ## 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`, report progress and results through the manager's events (`manager().downloadFinished.notify(...)` etc.) and `manager().notifyBusy(bool)`, 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 3b2614f4..3206cb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Settings > General > Appearance: Interface scale (100–250%) enlarges text, controls, icons and rows together after restarting. An externally set `QT_SCALE_FACTOR` takes precedence. An invalid saved scale loads as 100% without resetting the other interface preferences. +- Interface-scale changes offer a **Restart now** action after saving, using the normal close and shutdown path. AppImage builds relaunch the AppImage itself. +- `--ui-scale N` (also `--ui-scale=N`) and `PX_UI_SCALE` temporarily override the saved scale; `--ui-scale 100` recovers from an unusable saved value. An existing `QT_SCALE_FACTOR` still takes precedence. + ### Changed +- Settings identifies active interface-scale overrides and hides restart prompts while an override prevents saved changes from taking effect. +- The main window's initial size and position are clamped to the screen's available logical area. The layout's own minimum width (rail, sidebar and info pane) is not reduced, so very large interface scales on small screens can still make the window larger than the screen. +- 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. +- Removed dead code and collapsed single-use abstractions across core and UI (unused dialogs, widgets, QSS rules, test-only helpers, duplicate cached-database and mod-provider classes, nullable `ConfigService` paths); user-visible behaviour is unchanged. The undocumented `PX_SMOKE_EXIT_MS` environment variable is gone, and the app version now comes from `project(VERSION)` in CMake. +- Workshop cards are built from typed card data in one pass instead of repeatedly patching row maps, the mod information panel refreshes only the parts that changed (metadata, active order or diagnostics), providers take their metadata cache per discovery instead of through two channels, and the Organizer tree model takes ownership of the tree it displays instead of copying it. + +### Fixed +- Under Wayland with an interface scale above 100% (`QT_SCALE_FACTOR`), menus, tooltips and combo box lists opened away from their control, and in the rail, sidebar and other side panels also off by the panel's position; the popup's anchor is now passed to the compositor in the top-level surface's pixels. +- Malformed or unreadable community rules are ignored with a warning instead of failing the mod load, and rules that are no longer used are dropped so they cannot keep affecting diagnostics and sorting. +- Auto-sort no longer reads the live checker graph and rules from its worker thread, and a sort whose inputs change while it runs is discarded with a warning instead of overwriting newer user changes. +- An asynchronous startup failure (mod load or plugin init) now ends the application instead of leaving an open window with no running logic behind it. +- Workshop backend: Steam HTTP failures no longer reset the dependency crawler's cursor or pass for a complete dependency graph, and the legacy resolver uses the catalog's Steam transport and error classification. +- Moving from the Python app preserves dependency declaration order by rebuilding legacy metadata-cache rows from About.xml once. +- Snapshot discovery and retention follow Python's platform-specific filename matching, leaving unrelated case-mismatched files untouched on POSIX. +- 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 bbb0dffc..56d88cc9 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 00000000..d4506020 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,67 @@ +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) +endif() + +find_package(ZLIB REQUIRED) +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 00000000..ff3253fb --- /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 05244b04..078a93ac 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 31b81c3b..21287443 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 00000000..79f6b61b --- /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 cdabe758..b3f5baeb 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 84b93515..62c7ec09 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 2cbd2171..1c15ead6 100644 --- a/cf-workers/steam.ts +++ b/cf-workers/steam.ts @@ -6,6 +6,8 @@ const APP_ID = 294100; const DETAIL_TTL_MS = 15 * 60 * 1000; const QUERY_TTL_SECONDS = 5 * 60; const API_BATCH_SIZE = 100; +/** GetDetails accepts at most this many IDs per request. */ +export const STEAM_DETAILS_BATCH = API_BATCH_SIZE; const MAX_STEAM_ID = 18446744073709551615n; export type SteamSort = 'popular' | 'updated' | 'newest' | 'trending' | 'relevance'; @@ -120,6 +122,62 @@ function fileDetails(response: Record): SteamFile[] { return response.publishedfiledetails as SteamFile[]; } +/** Which optional GetDetails fields a caller needs; each caller states its own set. */ +export interface SteamDetailsOptions { + includeChildren: boolean; + includeTags: boolean; + includeKvTags: boolean; + includeVotes: boolean; + includeAdditionalPreviews: boolean; +} + +const CATALOG_DETAILS: SteamDetailsOptions = { + includeChildren: true, + includeTags: true, + includeKvTags: true, + includeVotes: true, + includeAdditionalPreviews: true, +}; + +/** + * One validated GetDetails request for at most `STEAM_DETAILS_BATCH` IDs. + * Transport and shape failures throw, so callers never mistake them for empty results. + */ +export async function fetchSteamFileDetails(env: Env, ids: string[], options: SteamDetailsOptions): Promise { + const url = steamUrl(env, 'IPublishedFileService', 'GetDetails'); + url.searchParams.set('input_json', JSON.stringify({ + publishedfileids: ids, + includechildren: options.includeChildren, + includetags: options.includeTags, + includekvtags: options.includeKvTags, + includevotes: options.includeVotes, + includeadditionalpreviews: options.includeAdditionalPreviews, + short_description: false, + })); + const response = responseObject(await steamJson(url)); + if (response.result !== undefined && response.result !== 1) throw new HttpError(502, 'Steam API request failed'); + return fileDetails(response); +} + +/** IDs of a QueryFiles page requested with `ids_only`; the other fields are not returned. */ +function queryFileIds(response: Record): string[] { + const details = response.publishedfiledetails; + if (details === undefined) { + if (response.total === 0) return []; + throw new HttpError(502, 'Steam API returned invalid file details'); + } + if (!Array.isArray(details)) throw new HttpError(502, 'Steam API returned invalid file details'); + const ids: string[] = []; + for (const value of details as unknown[]) { + if (!value || typeof value !== 'object' || !('publishedfileid' in value) || typeof value.publishedfileid !== 'string') { + throw new HttpError(502, 'Steam API returned invalid file details'); + } + try { validateSteamId(value.publishedfileid); } catch { throw new HttpError(502, 'Steam API returned an invalid Workshop ID'); } + ids.push(value.publishedfileid); + } + return ids; +} + async function authorsFor(env: Env, details: SteamFile[]): Promise> { const ids = [...new Set(details.map(file => file.creator).filter((id): id is string => typeof id === 'string' && /^[1-9]\d{0,19}$/.test(id)))]; const authors = new Map(); @@ -204,7 +262,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,30 +279,16 @@ 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) { const chunk = ids.slice(offset, offset + API_BATCH_SIZE); - const url = steamUrl(env, 'IPublishedFileService', 'GetDetails'); - url.searchParams.set('input_json', JSON.stringify({ - publishedfileids: chunk, - includechildren: true, - includetags: true, - includekvtags: true, - includevotes: true, - includeadditionalpreviews: true, - short_description: false, - })); - const response = responseObject(await steamJson(url)); - const details = fileDetails(response); + const details = await fetchSteamFileDetails(env, chunk, CATALOG_DETAILS); const returned = new Set(details.map(file => file.publishedfileid)); - if ((response.result !== undefined && response.result !== 1) || details.length !== chunk.length - || returned.size !== chunk.length || chunk.some(id => !returned.has(id))) { + if (details.length !== chunk.length || returned.size !== chunk.length || chunk.some(id => !returned.has(id))) { throw new HttpError(502, 'Steam API returned incomplete file details'); } for (const file of details) { @@ -250,7 +301,7 @@ export async function refreshSteamItems(env: Env, ids: string[]): Promise env.DB.prepare( @@ -323,11 +374,20 @@ export interface FilesPage { times_updated: number[]; } +async function queryFilesResponse(env: Env, input: Record): Promise> { + const url = steamUrl(env, 'IPublishedFileService', 'QueryFiles'); + url.searchParams.set('input_json', JSON.stringify(input)); + const response = responseObject(await steamJson(url)); + if (response.next_cursor !== undefined && typeof response.next_cursor !== 'string') { + throw new HttpError(502, 'Steam API returned invalid pagination'); + } + return response; +} + export async function queryFiles(env: Env, kind: K, query: SteamQuery): Promise> { const queryTypes: Record = { popular: 9, updated: 21, newest: 1, trending: 3, relevance: 12 }; const requiredTags = [...new Set([query.tag, query.version].filter((tag): tag is string => tag !== null))]; - const url = steamUrl(env, 'IPublishedFileService', 'QueryFiles'); - url.searchParams.set('input_json', JSON.stringify({ + const response = await queryFilesResponse(env, { query_type: queryTypes[query.sort], page: 1, cursor: query.cursor, @@ -347,10 +407,8 @@ export async function queryFiles(env: Env, kind: return_previews: true, return_short_description: false, cache_max_age_seconds: QUERY_TTL_SECONDS, - })); - const response = responseObject(await steamJson(url)); - if (typeof response.total !== 'number' || !Number.isSafeInteger(response.total) || response.total < 0 - || (response.next_cursor !== undefined && typeof response.next_cursor !== 'string')) { + }); + if (typeof response.total !== 'number' || !Number.isSafeInteger(response.total) || response.total < 0) { throw new HttpError(502, 'Steam API returned invalid pagination'); } const details = fileDetails(response); @@ -366,6 +424,36 @@ export async function queryFiles(env: Env, kind: } as FilesPage; } +/** + * IDs-only QueryFiles page for the legacy dependency crawler, using that crawler's + * established request options. Transport and shape failures throw instead of + * returning an empty page, so a fault can never look like crawl exhaustion. + */ +export async function querySteamIds(env: Env, limit: number, cursor: string): Promise<{ ids: string[]; next_cursor: string }> { + const response = await queryFilesResponse(env, { + query_type: 1, + page: 1, + cursor, + numperpage: limit, + creator_appid: APP_ID, + appid: APP_ID, + filetype: 0, + ids_only: true, + return_children: false, + return_vote_data: false, + return_tags: false, + return_kv_tags: false, + return_previews: false, + return_short_description: false, + return_metadata: false, + return_playtime_stats: false, + }); + return { + ids: queryFileIds(response), + next_cursor: typeof response.next_cursor === 'string' ? response.next_cursor : '', + }; +} + const QUERY_KEEP_MS = 7 * 24 * 60 * 60 * 1000; async function storedItems(db: D1Database, ids: string[]): Promise> { diff --git a/cf-workers/tests/catalog.test.ts b/cf-workers/tests/catalog.test.ts index f439c955..355c1077 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,121 @@ 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('a Steam detail outage keeps a stored collection root membership', async () => { + const rootId = '80011'; + const memberId = '80012'; + files[rootId] = { ...mod(rootId, 'Stored collection', [memberId]), file_type: 2 }; + 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, 'Stored collection', 1, 'OK', now, '', memberId), + db.prepare('INSERT INTO items (id,title,is_collection,status,updated_at,package_id,deps) VALUES (?,?,?,?,?,?,?)').bind(memberId, 'Stored member', 0, 'OK', now, '', ''), + ]); + detailFailures.set(rootId, 'http'); + try { + const response = await request(`/deps?id=${rootId}`); + 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, [memberId]); + } finally { + detailFailures.delete(rootId); + delete files[rootId]; + await db.prepare('DELETE FROM items WHERE id IN (?, ?)').bind(rootId, memberId).run(); + } +}); + +test('a Steam detail outage without a scraper is not reported as a successful dependency graph', async () => { + const rootId = '80021'; + detailFailures.set(rootId, 'http'); + try { + const response = await request(`/deps?id=${rootId}`); + assert.equal(response.status, 500); + assert.equal(await db.prepare('SELECT id FROM items WHERE id = ?').bind(rootId).first(), null); + } finally { + detailFailures.delete(rootId); + } +}); + +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 +1079,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/tests/worker.test.ts b/cf-workers/tests/worker.test.ts new file mode 100644 index 00000000..dc2eaa99 --- /dev/null +++ b/cf-workers/tests/worker.test.ts @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import { resolve as resolvePath } from 'node:path'; +import { after, before, beforeEach, test, type TestContext } from 'node:test'; +import { Miniflare } from 'miniflare'; +import { fetchSteamFileDetails } from '../steam'; +import { HttpError } from '../types'; +import type { Env } from '../types'; +import { crawlTick } from '../worker'; + +const KEY = 'fixture-only-key'; +const DETAILS_OPTIONS = { + includeChildren: true, includeTags: false, includeKvTags: true, includeVotes: false, includeAdditionalPreviews: false, +}; + +let worker: Miniflare; +let db: D1Database; + +function statements(sql: string): string[] { + const result: string[] = []; + let current = ''; + for (const line of sql.split('\n')) { + current += `${line}\n`; + const inTrigger = /CREATE TRIGGER/i.test(current) && !/^END;$/m.test(current); + if (line.trimEnd().endsWith(';') && !inTrigger) { result.push(current.trim()); current = ''; } + } + return result.filter(statement => statement && statement !== ';'); +} + +before(async () => { + worker = new Miniflare({ + workers: [{ + config: { + name: 'worker-tests', compatibilityDate: '2026-10-05', + manifest: { mainModule: 'worker.js', modules: { 'worker.js': { type: 'esm', contents: 'export default { fetch() { return new Response("D1 test host"); } };' } } }, + env: { DB: { type: 'd1', id: 'worker-tests' } }, + }, + }], + }); + db = await worker.getD1Database('DB'); + for (const file of (await readdir(resolvePath('migrations'))).filter(file => file.endsWith('.sql')).sort()) { + const sql = await readFile(resolvePath('migrations', file), 'utf8'); + await db.batch(statements(sql).map(statement => db.prepare(statement))); + } +}); +after(async () => { await worker?.dispose(); }); +beforeEach(async () => { + await db.batch([db.prepare('DELETE FROM items'), db.prepare('DELETE FROM crawl_state')]); + await db.prepare("INSERT INTO crawl_state (id, cursor, todo_ids, total_processed, updated_at) VALUES (1, ?, '', 0, 0)").bind('*').run(); +}); + +function env(extra: Partial = {}): Env { + return { DB: db, STEAM_API_KEY: KEY, ...extra }; +} + +async function savedState() { + return await db.prepare('SELECT cursor, todo_ids, total_processed FROM crawl_state WHERE id = 1') + .first<{ cursor: string; todo_ids: string; total_processed: number }>(); +} + +async function seedCursor(cursor: string): Promise { + await db.prepare('UPDATE crawl_state SET cursor = ? WHERE id = 1').bind(cursor).run(); +} + +function mockFetch(t: TestContext, handler: (url: URL, init?: RequestInit) => Response | Promise): URL[] { + const calls: URL[] = []; + t.mock.method(globalThis, 'fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + calls.push(url); + return await handler(url, init); + }); + return calls; +} + +function captureErrors(t: TestContext): string[] { + const messages: string[] = []; + t.mock.method(console, 'error', (...args: unknown[]) => { messages.push(args.map(value => String(value)).join(' ')); }); + return messages; +} + +test('a QueryFiles HTTP failure keeps the saved crawl cursor', async t => { + await seedCursor('page-7'); + const errors = captureErrors(t); + const calls = mockFetch(t, () => new Response('upstream down', { status: 500 })); + + await crawlTick(env()); + + assert.equal(calls.length, 3); + assert.deepEqual(await savedState(), { cursor: 'page-7', todo_ids: '', total_processed: 0 }); + assert.ok(errors.some(message => message.includes('keeping cursor "page-7"')), errors.join('\n')); +}); + +test('a Steam rate limit is reported without moving or resetting the cursor', async t => { + await seedCursor('page-9'); + const errors = captureErrors(t); + const calls = mockFetch(t, () => new Response('limited', { status: 429 })); + + await crawlTick(env()); + + assert.equal(calls.length, 1); + assert.deepEqual(await savedState(), { cursor: 'page-9', todo_ids: '', total_processed: 0 }); + assert.ok(errors.some(message => message.includes('HTTP 503')), errors.join('\n')); +}); + +test('an unparseable QueryFiles body cannot look like crawl exhaustion', async t => { + await seedCursor('page-3'); + const errors = captureErrors(t); + const calls = mockFetch(t, () => new Response('not json', { status: 200 })); + + await crawlTick(env()); + + assert.equal(calls.length, 3); + assert.deepEqual(await savedState(), { cursor: 'page-3', todo_ids: '', total_processed: 0 }); + assert.ok(errors.some(message => message.includes('HTTP 502')), errors.join('\n')); +}); + +test('a QueryFiles response without file details or total is an error, not an empty page', async t => { + await seedCursor('page-3'); + const errors = captureErrors(t); + const calls = mockFetch(t, () => Response.json({ response: {} })); + + await crawlTick(env()); + + assert.equal(calls.length, 1); + assert.deepEqual(await savedState(), { cursor: 'page-3', todo_ids: '', total_processed: 0 }); + assert.ok(errors.some(message => message.includes('invalid file details')), errors.join('\n')); +}); + +test('a successful empty page still restarts the crawl cycle', async t => { + await seedCursor('page-3'); + const calls = mockFetch(t, () => Response.json({ response: { total: 0, publishedfiledetails: [], next_cursor: '' } })); + + await crawlTick(env()); + + assert.equal(calls.length, 1); + assert.deepEqual(await savedState(), { cursor: '*', todo_ids: '', total_processed: 0 }); +}); + +test('an omitted details array with total zero is a legitimate empty page', async t => { + await seedCursor('page-3'); + mockFetch(t, () => Response.json({ response: { total: 0 } })); + + await crawlTick(env()); + + assert.deepEqual(await savedState(), { cursor: '*', todo_ids: '', total_processed: 0 }); +}); + +test('a successful page advances the cursor without scraping fresh items', async t => { + const now = Date.now(); + await db.batch([ + db.prepare('INSERT INTO items (id, title, is_collection, status, updated_at, package_id, deps) VALUES (?, ?, 0, ?, ?, ?, ?)').bind('501', 'Fresh A', 'OK', now, '', ''), + db.prepare('INSERT INTO items (id, title, is_collection, status, updated_at, package_id, deps) VALUES (?, ?, 0, ?, ?, ?, ?)').bind('502', 'Fresh B', 'OK', now, '', ''), + ]); + const calls = mockFetch(t, () => Response.json({ + response: { total: 2, publishedfiledetails: [{ publishedfileid: '501' }, { publishedfileid: '502' }], next_cursor: 'page-2' }, + })); + + await crawlTick(env()); + + assert.equal(calls.length, 1); + assert.deepEqual(await savedState(), { cursor: 'page-2', todo_ids: '', total_processed: 0 }); +}); + +test('stale crawl IDs resolve through the shared GetDetails operation with dependency options', async t => { + const inputs: Record[] = []; + const calls = mockFetch(t, url => { + if (url.pathname.includes('QueryFiles')) { + return Response.json({ response: { total: 1, publishedfiledetails: [{ publishedfileid: '601' }], next_cursor: 'page-2' } }); + } + assert.ok(url.pathname.includes('GetDetails'), `Unexpected Steam method ${url.pathname}`); + const input = JSON.parse(url.searchParams.get('input_json')!) as { publishedfileids: string[] } & Record; + inputs.push(input); + return Response.json({ response: { publishedfiledetails: input.publishedfileids.map(id => id === '601' + ? { publishedfileid: '601', result: 1, title: 'Crawl root', file_type: 0, children: [{ publishedfileid: '602' }] } + : { publishedfileid: '602', result: 1, title: 'Crawl dependency', file_type: 0, kvtags: [{ key: 'packageId', value: 'crawl.dependency' }] }) } }); + }); + + await crawlTick(env()); + + assert.equal(calls.length, 3); + assert.deepEqual(inputs[0], { + publishedfileids: ['601'], + includechildren: true, + includetags: false, + includekvtags: true, + includevotes: false, + includeadditionalpreviews: false, + short_description: false, + }); + assert.deepEqual(await savedState(), { cursor: 'page-2', todo_ids: '', total_processed: 1 }); + const rows = await db.prepare('SELECT id, title, status, package_id, deps FROM items ORDER BY id') + .all<{ id: string; title: string; status: string; package_id: string; deps: string }>(); + assert.deepEqual(rows.results, [ + { id: '601', title: 'Crawl root', status: 'OK', package_id: '', deps: '602' }, + { id: '602', title: 'Crawl dependency', status: 'OK', package_id: 'crawl.dependency', deps: '' }, + ]); +}); + +test('a Steam detail fault without a configured scraper fails instead of recording results', async t => { + const errors = captureErrors(t); + const calls = mockFetch(t, url => { + if (url.pathname.includes('QueryFiles')) { + return Response.json({ response: { total: 1, publishedfiledetails: [{ publishedfileid: '701' }], next_cursor: 'page-2' } }); + } + return new Response('unavailable', { status: 503 }); + }); + + await assert.rejects( + crawlTick(env()), + (error: unknown) => error instanceof HttpError && error.status === 502 && /SCRAPI is not configured/.test(error.message), + ); + + assert.equal(calls.length, 4); + assert.deepEqual(await savedState(), { cursor: '*', todo_ids: '', total_processed: 0 }); + assert.equal(await db.prepare('SELECT count(*) AS n FROM items').first<{ n: number }>().then(row => row?.n), 0); + assert.ok(errors.some(message => message.includes('HTTP 502')), errors.join('\n')); +}); + +test('the configured ScrapingAnt fallback still recovers a Steam detail fault', async t => { + const scraperKeys: Array = []; + mockFetch(t, (url, init) => { + if (url.hostname === 'api.scrapingant.com') { + scraperKeys.push(new Headers(init?.headers).get('x-api-key')); + return new Response('

Fallback mod
', { status: 200 }); + } + assert.equal(url.hostname, 'api.steampowered.com'); + if (url.pathname.includes('QueryFiles')) { + return Response.json({ response: { total: 1, publishedfiledetails: [{ publishedfileid: '801' }], next_cursor: 'page-2' } }); + } + return new Response('unavailable', { status: 503 }); + }); + + await crawlTick(env({ SCRAPI: 'fixture-scrapi-key' })); + + assert.deepEqual(scraperKeys, ['fixture-scrapi-key']); + assert.deepEqual(await savedState(), { cursor: 'page-2', todo_ids: '', total_processed: 1 }); + assert.deepEqual( + await db.prepare('SELECT title, status FROM items WHERE id = ?').bind('801').first<{ title: string; status: string }>(), + { title: 'Fallback mod', status: 'OK' }, + ); +}); + +test('the shared GetDetails operation classifies upstream faults instead of returning files', async t => { + let status = 429; + let payload: unknown = 'limited'; + const calls = mockFetch(t, () => typeof payload === 'string' ? new Response(payload, { status }) : Response.json(payload)); + + await assert.rejects( + fetchSteamFileDetails(env(), ['601'], DETAILS_OPTIONS), + (error: unknown) => error instanceof HttpError && error.status === 503, + ); + assert.equal(calls.length, 1); + + status = 200; + payload = { response: { publishedfiledetails: [{ publishedfileid: '601', result: 1, tags: 'not-an-array' }] } }; + await assert.rejects( + fetchSteamFileDetails(env(), ['601'], DETAILS_OPTIONS), + (error: unknown) => error instanceof HttpError && error.status === 502 && /invalid file metadata/.test(error.message), + ); + + await assert.rejects( + fetchSteamFileDetails({ DB: db }, ['601'], DETAILS_OPTIONS), + (error: unknown) => error instanceof HttpError && error.status === 503 && /STEAM_API_KEY/.test(error.message), + ); +}); diff --git a/cf-workers/worker.ts b/cf-workers/worker.ts index 3022544a..d7d11ebd 100644 --- a/cf-workers/worker.ts +++ b/cf-workers/worker.ts @@ -1,5 +1,8 @@ import { handleCatalogRequest } from './catalog'; import { indexTick } from './indexer'; +import { fetchSteamFileDetails, querySteamIds, STEAM_DETAILS_BATCH } from './steam'; +import type { SteamDetailsOptions } from './steam'; +import { HttpError } from './types'; import type { DependencyMessage, Env, SteamFile } from './types'; interface DependencyItem { @@ -34,16 +37,14 @@ interface DependencyRow { deps: string; } -interface SteamDetailsEnvelope { - response?: { publishedfiledetails?: SteamFile[] }; -} - -interface SteamQueryEnvelope { - response?: { - publishedfiledetails?: Array>; - next_cursor?: string; - }; -} +/** GetDetails fields the dependency crawler consumes. */ +const DEPENDENCY_DETAILS: SteamDetailsOptions = { + includeChildren: true, + includeTags: false, + includeKvTags: true, + includeVotes: false, + includeAdditionalPreviews: false, +}; interface CrawlState { cursor: string; @@ -90,7 +91,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); @@ -100,7 +100,6 @@ const CACHE_TTL_OK_COLLECTION = 1 * 60 * 60 * 1000; const CACHE_TTL_ERROR = 1 * 60 * 60 * 1000; const QUEUE_DEDUP_TTL_MS = 60 * 1000; const MAX_INLINE_SCRAPE = 200*4; -const STEAM_API_BATCH_SIZE = 200; const QUERY_MODS_PAGE_SIZE = 3000; const CRAWL_BATCH_SIZE = 200; @@ -140,10 +139,9 @@ function parseRequiredDeps(htmlBlock: string): string[] { // ============================================================================ async function fetchSteamPageHtml( - apiKey: string | undefined, + apiKey: string, modId: string ): Promise<{ html: string | null; httpStatus: number; error: string | null }> { - if (!apiKey) return { html: null, httpStatus: 0, error: 'SCRAPI not configured' }; const targetUrl = `https://steamcommunity.com/sharedfiles/filedetails/?id=${modId}`; const apiUrl = `https://api.scrapingant.com/v2/general?url=${encodeURIComponent(targetUrl)}&browser=false`; @@ -270,72 +268,28 @@ 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 }; } -async function fetchSteamApiDetails(apiKey: string | undefined, modIds: string[]): Promise { - if (!apiKey) return null; - const jsonPayload = { - publishedfileids: modIds, - includechildren: true, - includetags: false, - includekvtags: true - }; - const url = `https://api.steampowered.com/IPublishedFileService/GetDetails/v1/?key=${apiKey}&input_json=${encodeURIComponent(JSON.stringify(jsonPayload))}`; - - const startTime = Date.now(); - try { - const response = await fetch(url, { method: 'GET' }); - const duration = Date.now() - startTime; - - if (response.status === 429) { - console.warn(`[STEAM API RATE LIMITED] Batch of ${modIds.length} IDs returned 429`); - return null; - } - - if (!response.ok) { - console.error(`[STEAM API FAIL] HTTP ${response.status} (${duration}ms)`); - return null; - } - - const data = await response.json(); - const details = data?.response?.publishedfiledetails; - if (!Array.isArray(details)) { - console.error(`[STEAM API FAIL] Unexpected response shape`); - return null; - } - - return details; - } catch (err) { - console.error(`[STEAM API EXCEPTION] Batch of ${modIds.length}:`, err instanceof Error ? err.message : err); - return null; - } -} - -function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem[] } { +function processApiResults(details: SteamFile[]): ApiDependencyItem[] { const processed: ApiDependencyItem[] = []; for (const detail of details) { @@ -362,50 +316,13 @@ function processApiResults(details: SteamFile[]): { processed: ApiDependencyItem }); } - return { processed }; + return processed; } // ============================================================================ // 4. CRAWL — STALE MOD DISCOVERY VIA QueryFiles // ============================================================================ -async function queryMods(apiKey: string, cursor: string): Promise<{ ids: string[]; next_cursor: string }> { - const params = new URLSearchParams({ - key: apiKey, - query_type: '1', - page: '1', - cursor: cursor, - numperpage: String(QUERY_MODS_PAGE_SIZE), - creator_appid: '294100', - appid: '294100', - filetype: '0', - ids_only: 'true', - return_children: 'false', - return_vote_data: 'false', - return_tags: 'false', - return_kv_tags: 'false', - return_previews: 'false', - return_short_description: 'false', - return_metadata: 'false', - return_playtime_stats: 'false' - }); - const url = `https://api.steampowered.com/IPublishedFileService/QueryFiles/v1/?${params.toString()}`; - - const response = await fetch(url, { method: 'GET' }); - if (!response.ok) { - console.error(`[CRAWL] QueryFiles HTTP ${response.status}`); - return { ids: [], next_cursor: '' }; - } - - const data = await response.json(); - const details = data?.response?.publishedfiledetails; - const nextCursor = data?.response?.next_cursor || ''; - - const ids = Array.isArray(details) ? details.map(d => d.publishedfileid).filter(Boolean) : []; - - return { ids, next_cursor: nextCursor }; -} - async function findStaleIds(db: D1Database, ids: string[]): Promise { const stale: string[] = []; const now = Date.now(); @@ -465,7 +382,12 @@ async function saveCrawlState(db: D1Database, state: CrawlState): Promise ).run(); } -async function crawlTick(env: Env): Promise { +function steamFailure(error: unknown): string { + if (error instanceof HttpError) return `HTTP ${error.status}: ${error.message}`; + return error instanceof Error ? error.message : String(error); +} + +export async function crawlTick(env: Env): Promise { if (!env.STEAM_API_KEY) { console.warn('[CRAWL] No STEAM_API_KEY, skipping tick'); return; @@ -476,7 +398,14 @@ async function crawlTick(env: Env): Promise { if (state.todo_ids.length === 0) { console.log(`[CRAWL] Fetching page, cursor=${state.cursor || '(start)'}`); - const { ids, next_cursor } = await queryMods(env.STEAM_API_KEY, state.cursor); + // A transport or shape fault is not exhaustion: leaving the saved state untouched + // makes the next tick resume the same cursor instead of restarting the cycle. + const page = await querySteamIds(env, QUERY_MODS_PAGE_SIZE, state.cursor).catch(error => { + console.error(`[CRAWL] QueryFiles failed (${steamFailure(error)}), keeping cursor ${JSON.stringify(state.cursor)}`); + return null; + }); + if (!page) return; + const { ids, next_cursor } = page; if (ids.length === 0) { console.log('[CRAWL] Empty page, resetting cursor'); @@ -523,14 +452,17 @@ async function ensureCollectionRootDeps(env: Env, rootId: string): Promise { + // A detail outage keeps the stored membership; it does not empty the root collection. + console.error(`[ROOT COLLECTION] Steam details unavailable for ${rootId} (${steamFailure(error)})`); + return null; + }); if (!details) return false; const rootDetail = details.find(d => d.publishedfileid === rootId); if (!rootDetail || rootDetail.result !== 1) return false; const children = (rootDetail.children || []).map(c => c.publishedfileid).filter(Boolean); - if (children.length === 0) return false; const currentDeps = cached.data.deps || []; const depsChanged = children.length !== currentDeps.length || @@ -550,11 +482,11 @@ async function ensureCollectionRootDeps(env: Env, rootId: string): Promise { +async function scrapeViaScrapingAnt(env: Env, apiKey: string, modId: string): Promise { const now = Date.now(); console.log(`[SCRAPINGANT FALLBACK] Scraping Steam page for ID: ${modId}...`); - const { html, httpStatus, error } = await fetchSteamPageHtml(env.SCRAPI, modId); + const { html, httpStatus, error } = await fetchSteamPageHtml(apiKey, modId); if (httpStatus === 409 || httpStatus === 429) { console.warn(`[SCRAPINGANT FALLBACK] Rate limited on ${modId}`); @@ -622,18 +554,27 @@ async function scrapeInline(env: Env, missingIds: Set, rootId: string | while (toScrape.length > 0 && scrapedCount < MAX_INLINE_SCRAPE) { const batch: string[] = []; - while (batch.length < STEAM_API_BATCH_SIZE && toScrape.length > 0 && scrapedCount + batch.length < MAX_INLINE_SCRAPE) { + while (batch.length < STEAM_DETAILS_BATCH && toScrape.length > 0 && scrapedCount + batch.length < MAX_INLINE_SCRAPE) { const id = toScrape.shift()!; if (!scraped.has(id)) batch.push(id); } if (batch.length === 0) break; - const apiDetails = await fetchSteamApiDetails(env.STEAM_API_KEY, batch); + let apiDetails: SteamFile[] | null = null; + try { + apiDetails = await fetchSteamFileDetails(env, batch, DEPENDENCY_DETAILS); + } catch (error) { + // Exact fallback boundary: only a Steam fault reaches the paid scraper, and only when + // one is configured. Otherwise the fault propagates to /deps and the queue. + console.error(`[STEAM API FAIL] Batch of ${batch.length}: ${steamFailure(error)}`); + } if (!apiDetails) { + const scrapi = env.SCRAPI; + if (!scrapi) throw new HttpError(502, 'Steam API is unavailable and SCRAPI is not configured'); for (const id of batch) { if (scrapedCount >= MAX_INLINE_SCRAPE) break; - const result = await scrapeViaScrapingAnt(env, id); + const result = await scrapeViaScrapingAnt(env, scrapi, id); if (result.isRateLimited) { for (const remaining of toScrape) scraped.add(remaining); break; @@ -652,7 +593,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 +632,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); } } } @@ -858,7 +797,7 @@ export default { }, async scheduled(_event: ScheduledController, env: Env, ctx: ExecutionContext): Promise { - ctx.waitUntil(crawlTick(env)); + ctx.waitUntil(crawlTick(env).catch(error => console.error('[CRAWL] Tick failed', error instanceof Error ? error.message : error))); ctx.waitUntil(indexTick(env).catch(error => console.error('[INDEX] Tick failed', error instanceof Error ? error.message : error))); }, @@ -889,16 +828,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 00000000..b7075fba --- /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 00000000..886dca89 --- /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 9f68cbf5..9e8998f5 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 4b573bf1..00000000 --- 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 1563f11d..00000000 --- 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 282e8a7b..39014397 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 c09b32a9..d9e4e992 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 2100a0db..ae978505 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 cebddd76..00000000 --- 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 44d9ed2c..de108f35 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 45ec8652..00000000 --- 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 00000000..268881d3 --- /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 00000000..d2dfd576 --- /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 00000000..57c65e63 --- /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::modrim::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 00000000..7a6bcf49 --- /dev/null +++ b/src/app/main.cpp @@ -0,0 +1,326 @@ +#include +#include +#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" +#include "ui/window/restart.hpp" + +Q_IMPORT_QML_PLUGIN(PxModRimPlugin) + +namespace { + +constexpr int kWheelScrollLines = 8; +constexpr qint64 kLogMaxBytes = 10 * 1024 * 1024; +constexpr std::size_t kLogRetention = 5; + +enum class AppExit { kSuccess, kFailure, kRestart }; + +// 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()); + } +} + +QStringList commandLine(int argc, char* argv[]) { + QStringList arguments; + arguments.reserve(argc); + for (int index = 0; index < argc; ++index) { + arguments.append(QString::fromLocal8Bit(argv[index])); + } + return arguments; +} + +// Exports the scale as QT_SCALE_FACTOR, so it must run before QApplication +// exists. A user-set QT_SCALE_FACTOR is left alone. +px::modrim::ResolvedScale applyStartupScale(const QString& cliScale, + const QString& envScale) { + const px::modrim::ConfigService config(px::modrim::configDir()); + const auto scale = px::modrim::resolveUiScale( + px::modrim::loadUiScale(config), cliScale, envScale, + qEnvironmentVariable("QT_SCALE_FACTOR")); + if (scale.source != px::modrim::ScaleSource::kQtEnv && scale.scale != 100) { + qputenv("QT_SCALE_FACTOR", + px::modrim::uiScaleFactor(static_cast(scale.scale))); + } + return scale; +} + +// Runs once logging exists, which is after the scale had to be applied. +void warnInvalidScaleOverrides(const QString& cliScale, + const QString& envScale) { + for (const auto& [source, value] : {std::pair{"--ui-scale", cliScale}, + std::pair{"PX_UI_SCALE", envScale}}) { + if (!value.isNull() && !px::modrim::parseScaleOverride(value)) { + spdlog::warn("Ignoring invalid {} value: {}", source, + value.toStdString()); + } + } +} + +// Must run after the previous run has shut its plugins down and destroyed its +// services, so the new process never shares configuration or databases. +bool relaunch(const QStringList& originalArguments, bool keepQtScale) { + const auto environment = QProcessEnvironment::systemEnvironment(); + const QString program = px::modrim::restartProgram( + environment, QCoreApplication::applicationFilePath(), + [](const QString& path) { return QFileInfo(path).isFile(); }, + [](const QString& path) { return QFileInfo(path).canonicalFilePath(); }); + const auto command = px::modrim::restartCommand(program, originalArguments, + environment, keepQtScale); + QProcess process; + process.setProgram(command.program); + process.setArguments(command.arguments); + process.setProcessEnvironment(command.environment); + if (!process.startDetached()) { + spdlog::error("Could not restart PxModRim: {}", + process.errorString().toStdString()); + return false; + } + return true; +} + +// 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, + px::modrim::ResolvedScale initialScale) { + spdlog::info("Starting PxModRim"); + + px::modrim::ConfigService configService(px::modrim::configDir()); + + auto loadedCfg = + configService.load(px::modrim::kConfigFile); + if (!loadedCfg) { + spdlog::error("Failed to initialize configuration: {}", + loadedCfg.error().message.toStdString()); + co_return AppExit::kFailure; + } + auto uiPrefs = px::modrim::loadUiPrefs(configService); + if (!uiPrefs) { + spdlog::error("Failed to initialize UI preferences: {}", + uiPrefs.error().message.toStdString()); + co_return AppExit::kFailure; + } + + px::modrim::AppConfig cfg = std::move(*loadedCfg); + 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(px::modrim::kConfigFile, 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, *uiPrefs, initialScale); + 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 AppExit::kFailure; + } + 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 AppExit::kFailure; + } + 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 AppExit::kFailure; + } + window->startStartupUpdateCheck(); + } + + // Runs until the application quits (window close). + co_await qCoro(&qtApp, &QCoreApplication::aboutToQuit); + co_await teardown(appContext, *ctx); + co_return window->restartRequested() ? AppExit::kRestart : AppExit::kSuccess; +} + +// Ends the Qt event loop as soon as the run finishes. main() only enters the +// loop after the run has suspended, so a failure that happens later (a mod load +// or plugin init failure, say) would otherwise leave the app open with a dead +// task. +QCoro::Task runAppAndQuit(QApplication& qtApp, QSet disabled, + px::modrim::ResolvedScale initialScale) { + const AppExit exit = + co_await runApp(qtApp, std::move(disabled), std::move(initialScale)); + QCoreApplication::quit(); + co_return exit; +} + +} // namespace + +int main(int argc, char* argv[]) { + setEnvDefaults(); + const QStringList originalArguments = commandLine(argc, argv); + const QString cliScale = px::modrim::uiScaleArgument(originalArguments); + const QString envScale = qEnvironmentVariable("PX_UI_SCALE"); + const auto initialScale = applyStartupScale(cliScale, envScale); + + 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"))); + warnInvalidScaleOverrides(cliScale, envScale); + + 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 = runAppAndQuit(qtApp, std::move(*disabled), initialScale); + // A failure before the first suspension has already finished the run; + // otherwise run the event loop until the run finishes and quits it. + if (!run.isReady()) { + qtApp.exec(); + } + const auto result = QCoro::waitFor(std::move(run)); + if (result == AppExit::kRestart && + !relaunch(originalArguments, + initialScale.source == px::modrim::ScaleSource::kQtEnv)) { + return 1; + } + return result == AppExit::kFailure ? 1 : 0; +} diff --git a/src/app/plugins.cpp b/src/app/plugins.cpp new file mode 100644 index 00000000..bd030c20 --- /dev/null +++ b/src/app/plugins.cpp @@ -0,0 +1,116 @@ +#include "app/plugins.hpp" + +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/file_io.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 { + +Result> 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; + } + const auto bytes = readFileBytes(manifestPath); + if (!bytes) { + return std::unexpected( + Error{QStringLiteral("Cannot read %1").arg(manifestPath)}); + } + + const QString variant = + QString::fromUtf8(bytes->data(), static_cast(bytes->size())) + .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( + Error{QStringLiteral("Unknown workshop variant: %1").arg(variant)}); + } + return disabled; +} + +QStringList registerCorePlugins(CoreContext& ctx, ConfigService& config, + 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(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( + config, ctx.downloads(), [&ctx] { return ctx.allMods().values(); })); + } + return downloaders; +} + +void registerUiPlugins(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 00000000..15fdeb9e --- /dev/null +++ b/src/app/plugins.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include "core/error.hpp" + +namespace px::modrim { + +class AppContext; +class ConfigService; +class CoreContext; + +namespace organizer { +class OrganizerDb; +} + +// 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. +Result> 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 by the plugin's name in `selection.disabled` and the downloaders it +// needs. +void registerUiPlugins(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 00000000..0e72b196 --- /dev/null +++ b/src/core/CMakeLists.txt @@ -0,0 +1,13 @@ +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_compile_definitions(pxcore PRIVATE PX_APP_VERSION="${PROJECT_VERSION}") +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 00000000..f76148dd --- /dev/null +++ b/src/core/checker/checker.cpp @@ -0,0 +1,245 @@ +#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::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); +} + +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 = activeModDiagnostics(); + for (auto it = byUuid.begin(); it != byUuid.end();) { + if (it->hasErrors() || it->hasWarnings()) { + ++it; + } else { + it = byUuid.erase(it); + } + } + 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 00000000..57bb9dd6 --- /dev/null +++ b/src/core/checker/checker.hpp @@ -0,0 +1,100 @@ +#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 keyed by mod uuid. Mods without issues have empty entries. +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, + 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 ─────────────────────────────────── + + // Reapplies the full active-mod order and regenerates the affected + // diagnostics. + void reorder(const QStringList& orderedUuids); + + // ── Query ───────────────────────────────────────────────── + + const QHash& activeMods() const noexcept { + return m_activeMods; + } + const ConstraintGraph& graph() const noexcept { return m_graph; } + 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); + 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 00000000..0f7d6e9a --- /dev/null +++ b/src/core/checker/databases.cpp @@ -0,0 +1,392 @@ +#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 "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 {}; +} + +// 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"); + entries.insert(jsonText(rule, "oldWorkshopId"), std::move(info)); + } + } + spdlog::info("Loaded {} Use This Instead entries", entries.size()); + return entries; +} + +} // namespace + +template <> +const NoVersionWarningDb::Source& NoVersionWarningDb::source() { + static const Source value{QStringLiteral("No Version Warning"), + kNoVersionWarningUrl, kNoVersionWarningXml, + parseNoVersionWarning}; + return value; +} + +template <> +const UseThisInsteadDb::Source& UseThisInsteadDb::source() { + static const Source value{QStringLiteral("Use This Instead"), + kUseThisInsteadUrl, kReplacementsGz, + parseReplacements}; + return value; +} + +template +CachedDatabase::CachedDatabase(const ConfigService& configService, + QNetworkAccessManager& network) + : m_network(network), + m_cacheDir(configService.directory()), + m_path(QDir(m_cacheDir).filePath(source().fileName)) {} + +template +QCoro::Task CachedDatabase::ensure(bool force) { + if (!force) { + if (const auto stamp = fileStamp(m_path)) { + if (stamp != m_loadedStamp) { + m_entries = co_await QtConcurrent::run(source().parse, m_path); + m_loadedStamp = stamp; + } + co_return m_entries; + } + } + co_return co_await download(); +} + +template +std::optional CachedDatabase::cacheAgeS() const { + return fileAgeS(m_path); +} + +template +Entries CachedDatabase::loadIfExists() { + if (QFileInfo::exists(m_path)) { + m_entries = source().parse(m_path); + } + return m_entries; +} + +template +QCoro::Task CachedDatabase::download() { + QDir().mkpath(m_cacheDir); + + const Source& src = source(); + auto archive = co_await fetchArchive(m_network, src.url); + if (!archive) { + spdlog::warn("Failed to download {} DB: {}", src.label.toStdString(), + archive.error().message.toStdString()); + co_return Entries{}; + } + auto stored = co_await QtConcurrent::run( + storeArchiveEntry, std::move(*archive), src.fileName, m_path); + if (!stored) { + spdlog::warn("Failed to download {} DB: {}", src.label.toStdString(), + stored.error().message.toStdString()); + co_return Entries{}; + } + if (!*stored) { + co_return Entries{}; + } + + m_entries = co_await QtConcurrent::run(src.parse, m_path); + co_return m_entries; +} + +template class CachedDatabase>; +template class CachedDatabase>; + +} // namespace px::modrim::checker diff --git a/src/core/checker/databases.hpp b/src/core/checker/databases.hpp new file mode 100644 index 00000000..50427ece --- /dev/null +++ b/src/core/checker/databases.hpp @@ -0,0 +1,75 @@ +#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 a database file under the config directory and refreshes it from +// GitHub when the cache is missing or a refresh is forced. `Entries` selects +// the database: QSet for NoVersionWarning, the replacement map for +// UseThisInstead. +template +class CachedDatabase { + public: + CachedDatabase(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 empty entries + // and keeps the previous ones. + 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 + // entries. + Entries loadIfExists(); + + private: + struct Source { + QString label; + QString url; + QString fileName; + Entries (*parse)(const QString& path); + }; + static const Source& source(); + + QCoro::Task download(); + + QNetworkAccessManager& m_network; + QString m_cacheDir; + QString m_path; + Entries m_entries; + // Stamp of the file the current `m_entries` were parsed from. + std::optional m_loadedStamp; +}; + +using NoVersionWarningDb = CachedDatabase>; +using UseThisInsteadDb = CachedDatabase>; + +} // namespace checker +} // namespace px::modrim diff --git a/src/core/checker/graph.cpp b/src/core/checker/graph.cpp new file mode 100644 index 00000000..e1390486 --- /dev/null +++ b/src/core/checker/graph.cpp @@ -0,0 +1,289 @@ +#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::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; + } + addEdges(it.key(), rule->loadAfter, EdgeType::kLoadAfter, + EdgeOrigin::kCommunityRules, alternatives); + addEdges(it.key(), rule->loadBefore, EdgeType::kLoadBefore, + EdgeOrigin::kCommunityRules, alternatives); + } + } +} + +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, + {}); + } + + addEdges(pid, rules.loadAfter, EdgeType::kLoadAfter, origin, alternatives); + addEdges(pid, rules.loadBefore, EdgeType::kLoadBefore, origin, alternatives); + addEdges(pid, rules.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 00000000..b7955f7f --- /dev/null +++ b/src/core/checker/graph.hpp @@ -0,0 +1,102 @@ +#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; } + + // 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 edgesOfType(const PackageId& pid, EdgeType type) const; + QSet incomingOfType(const PackageId& pid, + EdgeType type) const; + + 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); + + // 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 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 00000000..7cae4705 --- /dev/null +++ b/src/core/checker/issues.cpp @@ -0,0 +1,259 @@ +#include "core/checker/issues.hpp" + +#include +#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{ + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Missing dependencies"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Dependency not satisfied: %1%2") + .arg(dep.packageId.str(), altNote), + }); + } + + 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; + const auto incompatibility = [this](QString detail) { + return ModIssue{ + .severity = IssueSeverity::kError, + .shortMessage = QStringLiteral("Incompatibilities detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = std::move(detail), + }; + }; + + for (const PackageId& incomp : mod.aboutRules.incompatibleWith) { + if (!ctx.activeMods.contains(incomp)) { + continue; + } + const AboutXmlModPtr other = ctx.activeMods.value(incomp); + issues.append(incompatibility( + QStringLiteral("Mod declares incompatibility with: %1 (%2)") + .arg(other->name, incomp.str()))); + } + + // 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( + incompatibility(QStringLiteral("Declared incompatible by: %1 (%2)") + .arg(other->name, edge.source.str()))); + } + + 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; + } + + struct Rule { + EdgeType type; + bool (*violated)(int idx, int otherIdx); + const char* message; + }; + const Rule rules[] = { + {EdgeType::kLoadBefore, + [](int idx, int otherIdx) { return idx >= otherIdx; }, + "Should load before: %1 (%2), but appears after (index %3 vs %4)"}, + {EdgeType::kLoadAfter, + [](int idx, int otherIdx) { return idx <= otherIdx; }, + "Should load after: %1 (%2), but appears before (index %3 vs %4)"}, + }; + for (const Rule& rule : rules) { + for (const ConstraintEdge& edge : ctx.graph.edgesOfType(pid, rule.type)) { + const int otherIdx = ctx.pidToIndex.value(edge.target, -1); + if (otherIdx < 0 || !rule.violated(idx, otherIdx)) { + continue; + } + const AboutXmlModPtr other = ctx.activeMods.value(edge.target); + const QString name = other ? other->name : edge.target.str(); + issues.append(ModIssue{ + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Load order violated"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QString::fromLatin1(rule.message) + .arg(name, edge.target.str()) + .arg(idx) + .arg(otherIdx), + }); + } + } + + 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; + for (const PackageId& member : cycle) { + members.append(member.str()); + } + issues.append(ModIssue{ + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Dependency cycle detected"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Participates in a dependency cycle involving: ") + + members.join(QStringLiteral(", ")), + }); + } + + 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{ + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Version mismatch"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = + QStringLiteral("Mod supports %1, but game is on %2") + .arg(supported.join(QStringLiteral(", ")), targetMajorMinor), + }}; +} + +QList ReplacementIssueChecker::check(const AboutXmlMod& mod, + const CheckContext& ctx) const { + const std::optional pfid = mod.publishedFileId(); + if (!pfid) { + return {}; + } + + const auto replacement = ctx.useThisInstead.constFind(*pfid); + if (replacement == ctx.useThisInstead.cend()) { + return {}; + } + const ReplacementInfo& info = replacement.value(); + + return {ModIssue{ + .severity = IssueSeverity::kWarning, + .shortMessage = QStringLiteral("Replacement available"), + .categoryDisplayName = categoryDisplayName(), + .detailMessage = QStringLiteral("Recommended replacement: %1 (%2) by %3") + .arg(info.name, info.packageid, info.author), + }}; +} + +} // namespace px::modrim::checker diff --git a/src/core/checker/issues.hpp b/src/core/checker/issues.hpp new file mode 100644 index 00000000..d8ab99b9 --- /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 00000000..51ae7441 --- /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 00000000..edee0e72 --- /dev/null +++ b/src/core/checker/models.hpp @@ -0,0 +1,58 @@ +#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 { + IssueSeverity severity; + QString shortMessage; + QString categoryDisplayName; + QString detailMessage; + + 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; + 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 00000000..b27e94d5 --- /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 00000000..b80ac8bc --- /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 00000000..4b5db20c --- /dev/null +++ b/src/core/config.cpp @@ -0,0 +1,481 @@ +#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 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 gameDirIn(const QString& root) { + return joinPath(root, {QStringLiteral("steamapps"), QStringLiteral("common"), + QStringLiteral("RimWorld")}); +} + +QString workshopDirIn(const QString& root, const QString& steamId) { + return joinPath(root, + {QStringLiteral("steamapps"), QStringLiteral("workshop"), + QStringLiteral("content"), steamId}); +} + +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) { + auto bytes = readFileBytes(vdfPath); + if (!bytes) { + spdlog::warn("Failed to parse libraryfolders.vdf"); + return {}; + } + const QString text = + QString::fromUtf8(bytes->data(), static_cast(bytes->size())); + + // 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 = gameDirIn(currentPath); + if (isDir(game)) { + return game; + } + } + } + return {}; +} + +QString findRimworldInSteamRoot(const QString& root, const QString& steamId) { + QString game = gameDirIn(root); + 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 workshopDirIn(steamRoot, 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 = gameDirIn(fallbackRoot); + if (isDir(fallback)) { + foundRoot = isDir(fallbackRoot) ? fallbackRoot : QString(); + return gamePathsFor(fallback, workshopDirIn(fallbackRoot, 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 = gameDirIn(steamRoot); + if (const QString app_bundle = find_mac_app(fallback); + !app_bundle.isEmpty()) { + return GamePaths{ + .game = app_bundle, + .local = joinPath(fallback, {QStringLiteral("Mods")}), + .workshop = workshopDirIn(steamRoot, 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"); + if (const QString game = gameDirIn(fallback); isDir(game)) { + return gamePathsFor(game, workshopDirIn(fallback, 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; + } + } + 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; +} + +namespace { + +Result readJsonObject(const QString& path) { + auto bytes = readFileBytes(path); + if (!bytes) { + return std::unexpected(bytes.error()); + } + glz::json_t document; + if (auto error = glz::read_json(document, *bytes)) { + return std::unexpected( + Error{QString::fromStdString(glz::format_error(error, *bytes))}); + } + if (!document.is_object()) { + return std::unexpected( + Error{QStringLiteral("top-level value is not an object")}); + } + return std::move(document.get_object()); +} + +} // namespace + +JsonObject ConfigService::loadRaw(const QString& filename) const { + auto raw = readJsonObject(filePath(filename)); + return raw ? std::move(*raw) : JsonObject{}; +} + +Result> ConfigService::loadVersioned( + const QString& filename) const { + const QString path = filePath(filename); + auto loaded = readJsonObject(path); + if (!loaded) { + spdlog::warn("Failed to load {}: {}", filename.toStdString(), + loaded.error().message.toStdString()); + return std::optional{}; + } + JsonObject raw = std::move(*loaded); + + // 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; +} + +} // namespace px::modrim diff --git a/src/core/config.hpp b/src/core/config.hpp new file mode 100644 index 00000000..252ea45d --- /dev/null +++ b/src/core/config.hpp @@ -0,0 +1,192 @@ +#pragma once + +#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(); + +inline const QString kConfigFile = QStringLiteral("config.json"); + +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(); + +} // namespace px::modrim diff --git a/src/core/constants.hpp b/src/core/constants.hpp new file mode 100644 index 00000000..0c968efe --- /dev/null +++ b/src/core/constants.hpp @@ -0,0 +1,66 @@ +#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 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 00000000..0a0ec25d --- /dev/null +++ b/src/core/context.cpp @@ -0,0 +1,176 @@ +#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); + invalidateSortInputs(); +} + +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); + } + invalidateSortInputs(); + 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(); + std::optional order = co_await m_sortService->sortActiveMods(); + const double elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - started) + .count(); + if (!order) { + co_return AutoSortResult{.count = 0, .elapsedMs = elapsedMs, .stale = true}; + } + m_activeUuids = std::move(*order); + m_diagnosticsService->reorder(m_activeUuids); + // A sort applied here is itself a change to the active order, so any other + // sort still running must not overwrite it. + invalidateSortInputs(); + 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 AutoSortResult{ + .count = count, .elapsedMs = elapsedMs, .stale = false}; +} + +void CoreContext::updateConfig(AppConfig cfg) { + m_cfg = std::move(cfg); + refreshGameVersion(); + m_configChanged.notify(m_cfg); + invalidateSortInputs(); +} + +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"); +} + +DownloadManager& CoreContext::downloads() const { + auto* manager = + dynamic_cast(m_plugins.get(kDownloadsManagerName)); + Q_ASSERT(manager != nullptr); + return *manager; +} + +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 00000000..f0e3fe57 --- /dev/null +++ b/src/core/context.hpp @@ -0,0 +1,140 @@ +#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" + +namespace px::modrim { + +class ActivationService; +class ConfigService; +class DiagnosticsService; +class DownloadManager; +class ModService; +class SortService; + +namespace services { +class GameLauncher; +} + +// Outcome of CoreContext::autoSort(). A stale sort was discarded because a +// running sort's inputs changed; the previous order is still in place and +// `count` is zero. +struct AutoSortResult { + int count = 0; + double elapsedMs = 0; + bool stale = false; +}; + +// 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: + // `configService` is not owned and must outlive the context. + static std::unique_ptr create(AppConfig cfg, + ConfigService& configService); + + ~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. A sort whose + // inputs changed while its worker ran is discarded, leaving the current + // order untouched. + 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); + + 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; } + + // Incremented whenever something a running sort reads changes: the active + // order or membership, the discovered mods, the config or the checker's graph + // and rules. A sort captures the value before it reads its inputs and drops + // its result if the value moved on. + void invalidateSortInputs() noexcept { ++m_sortInputsRevision; } + quint64 sortInputsRevision() const noexcept { return m_sortInputsRevision; } + + ConfigService& configService() const noexcept { return m_configService; } + 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; + quint64 m_sortInputsRevision = 0; + // 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 00000000..4be04c89 --- /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 00000000..28286e86 --- /dev/null +++ b/src/core/downloads/downloader.cpp @@ -0,0 +1,18 @@ +#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); } + +} // namespace px::modrim diff --git a/src/core/downloads/downloader.hpp b/src/core/downloads/downloader.hpp new file mode 100644 index 00000000..2a54350d --- /dev/null +++ b/src/core/downloads/downloader.hpp @@ -0,0 +1,60 @@ +#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; + + protected: + DownloadManager& manager() const noexcept { return m_manager; } + + 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 00000000..9121893b --- /dev/null +++ b/src/core/downloads/manager.cpp @@ -0,0 +1,156 @@ +#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); +} + +void DownloadManager::notifyBusy(bool busy) { + if (busy) { + m_cancelled = false; + } + busyChanged.notify(isDownloading()); +} + +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 { + for (const auto* downloader : m_downloaders) { + if (auto id = downloader->updatableId(mod)) { + return id; + } + } + return std::nullopt; +} + +QStringList DownloadManager::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; +} + +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 00000000..03eaf214 --- /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); + + // Called by a downloader when its batch starts or ends. + void notifyBusy(bool busy); + + 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: + 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 00000000..50ac1053 --- /dev/null +++ b/src/core/downloads/steam.cpp @@ -0,0 +1,636 @@ +#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/downloads/manager.hpp" +#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)); +} + +// 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::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"), + {{"workshop_auto_update_hours", "auto_update_hours"}, + {"workshop_parallel_items", "parallel_items"}, + {"workshop_threads_per_item", "threads_per_item"}, + {"workshop_proxy", "proxy"}, + {"workshop_connect_timeout", "connect_timeout"}, + {"workshop_stall_timeout", "stall_timeout"}}) { + 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; + } + 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; + QSet seen; + for (const auto& mod : m_installedMods()) { + const auto id = updatableId(*mod); + if (!id || seen.contains(*id)) continue; + seen.insert(*id); + 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.")}); + } + modIds.removeDuplicates(); + std::vector itemIds; + itemIds.reserve(modIds.size()); + for (const auto& id : modIds) { + const auto parsed = parseItemId(id); + if (!parsed) { + co_return std::unexpected(Error{QStringLiteral( + "Published file IDs must be ASCII decimal numbers within uint64.")}); + } + itemIds.push_back(*parsed); + } + 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.")}); + } + + 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()); + manager().notifyBusy(true); + manager().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), std::move(itemIds), 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) { + manager().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, + std::vector itemIds, + 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()); + manager().statusMessageChanged.notify(message); + failItems(message); + co_return; + } + + if (!m_client) { + manager().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()); + manager().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); + + // 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); + }; + + manager().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(); + manager().notifyBusy(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); + manager().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 = {}, + }; + } + manager().downloadItemStatusChanged.notify(status); + manager().downloadProgress.notify(batch.progress()); +} + +void SteamDownloader::recordSynced(const QStringList& ids) { + if (ids.isEmpty()) { + return; + } + const double now = QDateTime::currentMSecsSinceEpoch() / 1000.0; + 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()) { + manager().downloadItemTitled.notify( + DownloadItemTitle{.modId = modId, .title = title}); + } + if (batch.resolved >= batch.total) { + manager().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) { + manager().downloadItemStatusChanged.notify(DownloadItemStatus{ + .modId = modId, + .status = QStringLiteral("downloading"), + .bytesDone = done, + .bytesTotal = total, + .error = {}, + }); + manager().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, QDateTime::currentMSecsSinceEpoch() / 1000.0); + 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 00000000..e44db227 --- /dev/null +++ b/src/core/downloads/steam.hpp @@ -0,0 +1,220 @@ +#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; } + + // 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, + std::vector itemIds, 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 00000000..a2ca741d --- /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 00000000..233cdff7 --- /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 00000000..a0f7845e --- /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 00000000..90cefc97 --- /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 00000000..c0ca74b4 --- /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 00000000..1d92369f --- /dev/null +++ b/src/core/loading.cpp @@ -0,0 +1,66 @@ +#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(); +} + +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 00000000..75c0c113 --- /dev/null +++ b/src/core/loading.hpp @@ -0,0 +1,62 @@ +#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); + + [[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 00000000..efe32498 --- /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 00000000..f395d771 --- /dev/null +++ b/src/core/migrator.hpp @@ -0,0 +1,28 @@ +#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, + const QString& label); + +} // namespace px::modrim diff --git a/src/core/mod_service.cpp b/src/core/mod_service.cpp new file mode 100644 index 00000000..d067b19e --- /dev/null +++ b/src/core/mod_service.cpp @@ -0,0 +1,237 @@ +#include "core/mod_service.hpp" + +#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) { + m_metadataCache = std::make_unique( + services::metadataCachePath(ctx.configService().directory())); + } +} + +void ModService::resetProviders(providers::ProviderList providers) { + m_providers = std::move(providers); +} + +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; + co_await diagnostics; + const auto loaded = co_await impact; + timer.adopt(discoverTimer); + timer.adopt(diagnosticsTimer); + + if (!discovered) { + co_return std::unexpected(discovered.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 {}; +} + +QString ModService::snapshotsDir() const { + return QDir(m_ctx.configService().directory()) + .filePath(QStringLiteral("snapshots")); +} + +QCoro::Task> ModService::reload(bool full) { + co_return co_await runRefreshPipeline(QStringLiteral("reload"), full); +} + +QStringList ModService::getSnapshots() const { + return px::modrim::getSnapshots(snapshotsDir()); +} + +QCoro::Task ModService::restoreSnapshot(QString snapshotPath) { + const QString configFolder = configFolderOf(m_ctx); + const QString snapshots = snapshotsDir(); + if (configFolder.isEmpty()) { + spdlog::warn("Cannot restore snapshot: configuration is incomplete"); + co_return false; + } + + const QString configPath = modsConfigPath(configFolder); + 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); + if (configFolder.isEmpty()) { + 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 = modsConfigPath(configFolder); + const QString targetVersion = m_ctx.targetVersion(); + const int maxSnapshots = m_ctx.config().max_snapshots; + const QString snapshotsPath = snapshotsDir(); + + 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 00000000..35675e89 --- /dev/null +++ b/src/core/mod_service.hpp @@ -0,0 +1,77 @@ +#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); + 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); + + // 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); + QString 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 00000000..88d0d41c --- /dev/null +++ b/src/core/models/metadata/parsing.cpp @@ -0,0 +1,297 @@ +#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)); + } + } +} + +template +void assignIfPresent(Target& target, const QString& text) { + if (!text.isEmpty()) { + target = text; + } +} + +} // 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"}; + constexpr size_t kLoadBefore = 0; + constexpr size_t kLoadAfter = 1; + constexpr size_t kIncompatibleWith = 2; + constexpr size_t kForceLoadBefore = 3; + constexpr size_t kForceLoadAfter = 4; + 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")) { + assignIfPresent(declaredName, scalarText(child)); + } else if (tag == QLatin1StringView("description")) { + assignIfPresent(description, scalarText(child)); + } else if (tag == QLatin1StringView("modVersion")) { + assignIfPresent(mod->modVersion, scalarText(child)); + } else if (tag == QLatin1StringView("url")) { + assignIfPresent(mod->url, scalarText(child)); + } else if (tag == QLatin1StringView("modIconPath")) { + assignIfPresent(mod->modIconPath, scalarText(child)); + } 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. + assignIfPresent(versionDescription, + rawText(versionSection(child, shortVersion)).trimmed()); + } 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[kLoadBefore].append(ruleLists[kForceLoadBefore]); + ruleLists[kLoadAfter].append(ruleLists[kForceLoadAfter]); + mod->aboutRules.loadBefore = CaseInsensitiveSet(ruleLists[kLoadBefore]); + mod->aboutRules.loadAfter = CaseInsensitiveSet(ruleLists[kLoadAfter]); + mod->aboutRules.incompatibleWith = + CaseInsensitiveSet(ruleLists[kIncompatibleWith]); + 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 00000000..c6faf66a --- /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 00000000..08770ead --- /dev/null +++ b/src/core/models/metadata/structures.cpp @@ -0,0 +1,132 @@ +#include "core/models/metadata/structures.hpp" + +#include +#include +#include +#include +#include +#include + +#include "core/utils.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(); +} + +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; + } + QString candidate = text.trimmed(); + if (isAsciiDigits(candidate)) { + return candidate; + } + } else if (declaration.error() == QFileDevice::PermissionsError || + QFileInfo::exists(declaration.fileName())) { + return std::nullopt; + } + + const QString folder = pathLeaf(directory); + return isAsciiDigits(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); + } +} + +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); +} + +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::publishedFileId() const { + if (!m_workshopIdReady) { + m_workshopId = + m_directory ? readWorkshopNumber(*m_directory) : std::nullopt; + m_workshopIdReady = true; + } + return m_workshopId; +} + +} // namespace px::modrim diff --git a/src/core/models/metadata/structures.hpp b/src/core/models/metadata/structures.hpp new file mode 100644 index 00000000..8dcad94a --- /dev/null +++ b/src/core/models/metadata/structures.hpp @@ -0,0 +1,194 @@ +#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); + + bool contains(QStringView value) const; + bool contains(const CaseInsensitiveStr& value) const; + void add(QStringView value); + void add(const CaseInsensitiveStr& value); + + 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; + 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; + + QString uuid() const; + const std::optional& modPath() const noexcept { return m_directory; } + Result setModPath(const QString& path); + std::optional publishedFileId() const; + + private: + QString m_identity = makeIdentity(); + std::optional m_directory; + mutable bool m_workshopIdReady = false; + mutable std::optional m_workshopId; + + 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; +}; + +using ListedModPtr = std::shared_ptr; + +struct ModsConfig { + QString version; + QList activeMods; + QList knownExpansions; +}; + +struct ReplacementInfo { + QString name; + QString author; + QString packageid; +}; + +// 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) + +} // 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 00000000..4a2a88b3 --- /dev/null +++ b/src/core/models/view/diagnostics.hpp @@ -0,0 +1,26 @@ +#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 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.hpp b/src/core/models/view/sidebar.hpp new file mode 100644 index 00000000..1f0a810b --- /dev/null +++ b/src/core/models/view/sidebar.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include + +namespace px::modrim::view { + +// 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; + + 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 00000000..dcc12a50 --- /dev/null +++ b/src/core/mods_config.cpp @@ -0,0 +1,321 @@ +#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()); +} + +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()) { + for (const auto& dlc : kRimworldDlcMetadata) { + config.knownExpansions.append(CaseInsensitiveStr(QString(dlc.packageId))); + } + } + 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) { + // pathlib's glob is case-sensitive on POSIX, unlike QDir::match. +#ifdef Q_OS_WIN + constexpr auto kCaseSensitivity = Qt::CaseInsensitive; +#else + constexpr auto kCaseSensitivity = Qt::CaseSensitive; +#endif + 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 (!name.startsWith(QStringLiteral("ModsConfig_"), kCaseSensitivity) || + !name.endsWith(QStringLiteral(".xml"), kCaseSensitivity) || + !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); + } + while (const pugi::xml_node li = node.child("li")) { + node.remove_child(li); + } + 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); +} + +QString modsConfigPath(const QString& configFolder) { + return QDir(configFolder).filePath(QStringLiteral("ModsConfig.xml")); +} + +QStringList getSnapshots(const QString& snapshotsDir) { + 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 00000000..32cb4d15 --- /dev/null +++ b/src/core/mods_config.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include "core/error.hpp" +#include "core/models/metadata/structures.hpp" + +namespace px::modrim { + +// /ModsConfig.xml. +QString modsConfigPath(const QString& configFolder); + +// 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 00000000..f490b2d6 --- /dev/null +++ b/src/core/organizer/db.cpp @@ -0,0 +1,799 @@ +#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)}; +} + +Error missingFolderError() { + return Error{QStringLiteral("That folder no longer exists.")}; +} + +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(missingFolderError()); + } + 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(missingFolderError()); + } + if (*parentDepth + height->value(0).toInt() > kMaxDepth) { + return std::unexpected(depthLimitError()); + } + return {}; +} + +Result checkChildDepth(QSqlDatabase& db, qint64 parentId) { + auto parentDepth = folderDepth(db, parentId); + if (!parentDepth) { + return std::unexpected(parentDepth.error()); + } + if (*parentDepth + 1 > 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 field, op, pattern, folder_id FROM rules " + "ORDER BY position ASC, id ASC"), + {}, [&state, &corruptRule](QSqlQuery& row) { + const auto field = parseRuleField(row.value(0).toString()); + const auto op = parseRuleOp(row.value(1).toString()); + if (!field || !op) { + corruptRule = true; + return; + } + state.rules.append(RuleSpec{.field = *field, + .op = *op, + .pattern = row.value(2).toString(), + .folderId = row.value(3).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) { + if (auto fits = checkChildDepth(db, parentId); !fits) { + return std::unexpected(fits.error()); + } + 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()); + } + 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()); + } + } + return {}; +} + +Result appendNamedRuleRows(QSqlDatabase& db, qint64 parentId, + const QStringList& newFolders, + const QList& rules) { + if (!newFolders.isEmpty()) { + if (auto fits = checkChildDepth(db, parentId); !fits) { + return fits; + } + } + 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. + discard().waitForFinished(); +} + +QFuture OrganizerDb::discard() { + return QtConcurrent::run( + &m_pool, [name = m_connectionName] { discardConnection(name); }); +} + +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(loadState); +} + +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 discard(); } + +} // namespace px::modrim::organizer diff --git a/src/core/organizer/db.hpp b/src/core/organizer/db.hpp new file mode 100644 index 00000000..707657df --- /dev/null +++ b/src/core/organizer/db.hpp @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#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) { + return createFolderWith(parentId, std::move(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: + // Closes and removes the connection on the worker thread. + QFuture discard(); + + // 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 00000000..8d4a0878 --- /dev/null +++ b/src/core/organizer/defaults.cpp @@ -0,0 +1,85 @@ +#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 ids(const QString& folder, RuleOp op, + std::initializer_list patterns) { + QList rules; + for (const char* pattern : patterns) { + rules.append( + {folder, RuleField::kPackageId, op, QString::fromLatin1(pattern)}); + } + return rules; +} + +QList buildStandardRules() { + QList rules; + rules += ids("Official", RuleOp::kPrefix, {"ludeon.rimworld"}); + rules += ids("Frameworks & Libraries", RuleOp::kEquals, + { + "brrainz.harmony", + "unlimitedhugs.hugslib", + "erdelf.humanoidalienraces", + "oskarpotocki.vanillafactionsexpanded.core", + "imranfish.xmlextensions", + "zetrith.prepatcher", + "jecrell.jecstools", + "bs.fishery", + "smashphil.vehicleframework", + }); + rules += ids("Performance", RuleOp::kEquals, + { + "bs.performance", + "krkr.rocketman", + "taranchuk.performanceoptimizer", + }); + rules += + ids("Performance", RuleOp::kPrefix, {"dubwise.dubsperformanceanalyzer"}); + rules += ids("Quality of Life", RuleOp::kEquals, + { + "brrainz.achtung", + "brrainz.cameraplus", + "unlimitedhugs.allowtool", + "jaxe.rimhud", + "mehni.pickupandhaul", + "avilmask.commonsense", + "dhultgren.smarterconstruction", + "roolo.searchanddestroy", + }); + rules += ids("Vanilla Expanded", RuleOp::kPrefix, + { + "vanillaexpanded.", + "oskarpotocki.", + "vanillaracesexpanded.", + "vanillaquestsexpanded.", + "vanillastorytellersexpanded.", + }); + rules.append({"Vanilla Expanded", RuleField::kAuthor, RuleOp::kContains, + "oskar potocki"}); + rules += ids("Alpha Mods", RuleOp::kPrefix, {"sarg."}); + rules += ids("Combat Extended", RuleOp::kPrefix, {"ceteam."}); + rules.append({"Combat Extended", RuleField::kName, RuleOp::kContains, + "combat extended"}); + rules += ids("Dubs Mods", RuleOp::kPrefix, {"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 00000000..3fceac9e --- /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 00000000..627d8c83 --- /dev/null +++ b/src/core/organizer/models.hpp @@ -0,0 +1,113 @@ +#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; +}; + +// 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 00000000..06c493a3 --- /dev/null +++ b/src/core/organizer/resolve.cpp @@ -0,0 +1,416 @@ +#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 RuleSpec& 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; +} + +namespace { + +QString modAuthor(const ListedMod& mod) { + if (const auto* about = dynamic_cast(&mod)) { + return about->authors.join(QStringLiteral(", ")); + } + return {}; +} + +} // namespace + +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. + 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.tags.isEmpty()) { + QHash tagCounts; + for (const ListedModPtr& mod : mods) { + const auto pid = modPackageId(*mod); + if (!pid) { + continue; + } + for (qint64 tagId : state.modTags.value(*pid)) { + 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 RuleSpec& 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 00000000..f35c1cbe --- /dev/null +++ b/src/core/organizer/resolve.hpp @@ -0,0 +1,107 @@ +#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); + +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); + +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 00000000..d26604c3 --- /dev/null +++ b/src/core/organizer/service.cpp @@ -0,0 +1,589 @@ +#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 FolderNode* findNode(const FolderNode& root, qint64 folderId) { + for (const FolderNode* node : root.walk()) { + if (node->folder.id == folderId) { + return node; + } + } + return nullptr; +} + +QStringList uuidsOf(const QList& leaves) { + QStringList uuids; + for (const ModLeaf& leaf : leaves) { + uuids.append(leaf.uuid); + } + return uuids; +} + +Result cleanName(const QString& name, const QString& what) { + 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) + : OrganizerService( + [&ctx]() -> const QHash& { + return ctx.allMods(); + }, + [&ctx]() -> const QStringList& { return ctx.activeUuids(); }, 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 { + return m_allModsFn(); +} + +const QStringList& OrganizerService::activeUuids() const { + return m_activeUuidsFn(); +} + +QCoro::Task OrganizerService::init() { + if (auto loaded = co_await commit(); !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()); + } +} + +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) { + return createFolderFrom(std::move(name), {}, parentId); +} + +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, std::move(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()); + } + if (cleanPackageIds(packageIds).isEmpty()) { + co_return {}; + } + if (auto placed = co_await m_db.place(std::move(packageIds), 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) { + if (cleanPackageIds(packageIds).isEmpty()) { + co_return {}; + } + if (auto unplaced = co_await m_db.unplace(std::move(packageIds)); !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) { + 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 (cleanPackageIds(packageIds).isEmpty() || + (add.isEmpty() && remove.isEmpty())) { + co_return {}; + } + if (auto updated = + co_await m_db.setModTags(std::move(packageIds), 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 RuleSpec& 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. + 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(); + const FolderNode* node = findNode(root, folderId); + return uuidsOf(recursive ? node->allMods() : node->mods); +} + +Result OrganizerService::folderToggle(qint64 folderId, + bool recursive) const { + if (auto folder = findFolder(folderId); !folder) { + return std::unexpected(folder.error()); + } + const FolderNode root = tree(); + const FolderNode* node = findNode(root, folderId); + const QStringList uuids = uuidsOf(recursive ? node->allMods() : node->mods); + const CheckState check = recursive ? node->check : node->ownCheck(); + if (check == CheckState::kOn) { + return FolderToggle{QStringList{}, uuids}; + } + return FolderToggle{uuids, QStringList{}}; +} + +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); + + QSet targets; + for (const Folder& candidate : state().folders) { + if (subtree.contains(candidate.id) || candidate.id == folder->parentId) { + continue; + } + if (checkDepth(candidate.id, treeHeight) && + checkUniqueFolderName(candidate.id, folder->name)) { + 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 00000000..e2554dec --- /dev/null +++ b/src/core/organizer/service.hpp @@ -0,0 +1,121 @@ +#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::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; + + 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 00000000..c76d6458 --- /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 00000000..a7d4e88f --- /dev/null +++ b/src/core/plugin_config.hpp @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/events.hpp" + +namespace px::modrim { + +template +concept PluginSettings = + std::equality_comparable && std::default_initializable; + +// 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. +template +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 +PluginConfig::PluginConfig(ConfigService& service, const QString& name, + const QHash& legacy) + : m_service(service), + m_filename(QStringLiteral("plugins/%1.json").arg(name)) { + // plugins/*.json is unversioned, so loading cannot fail with a migration + // error. + auto loaded = m_service.loadExisting(m_filename); + Q_ASSERT(loaded.has_value()); + std::optional stored = loaded.value_or(std::nullopt); + m_value = stored ? std::move(*stored) : seed(legacy); +} + +template +T PluginConfig::seed(const QHash& legacy) const { + if (legacy.isEmpty()) { + return T{}; + } + const auto raw = m_service.loadRaw(kConfigFile); + 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 +void PluginConfig::update(T value) { + if (value == m_value) { + return; + } + m_value = std::move(value); + store(m_value); + changed.notify(m_value); +} + +template +void PluginConfig::store(const T& value) const { + 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 00000000..3e7acc05 --- /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 00000000..c61cea93 --- /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 00000000..2306bf51 --- /dev/null +++ b/src/core/providers/base.cpp @@ -0,0 +1,171 @@ +#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) + : m_providerId(std::move(providerId)), + m_color(std::move(color)), + m_path(std::move(path)), + m_pool(pool) {} + +QCoro::Task> BaseModProvider::discover( + QString targetVersion, Profiler* timer, MetadataCacheStore* metadataCache, + bool forceReparse) { + Profiler ownTimer; + Profiler& tm = timer ? *timer : ownTimer; + + spdlog::debug("{} scanning: {}", m_providerId.toStdString(), + m_path.toStdString()); + QList dirs; + { + auto scope = tm("scan_dir"); + dirs = co_await runOnPool>( + nullptr, [this] { return scanDirectories(); }); + } + + auto discovered = co_await loadMods(std::move(dirs), std::move(targetVersion), + tm, metadataCache, forceReparse); + if (discovered) { + spdlog::info("{} discovered {} mods", m_providerId.toStdString(), + discovered->size()); + } + co_return discovered; +} + +QCoro::Task> BaseModProvider::loadMods( + QList dirs, QString targetVersion, Profiler& timer, + MetadataCacheStore* metadataCache, bool forceReparse) { + const QList candidates = + co_await runOnPool>( + nullptr, [dirs = std::move(dirs)] { return statCandidates(dirs); }); + + ModMap result; + QList missing; + if (metadataCache && !forceReparse) { + auto scope = timer("cache_lookup"); + CacheLookup lookup = + co_await metadataCache->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 (metadataCache) { + 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 metadataCache->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) { + 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 00000000..01e090d4 --- /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` is optional and not owned; + // it must outlive the provider. + BaseModProvider(QString providerId, QString color, QString path, + QThreadPool* pool = 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; } + + // `metadataCache` serves this call only; null parses everything. + QCoro::Task> discover( + QString targetVersion, Profiler* timer = nullptr, + MetadataCacheStore* metadataCache = nullptr, bool forceReparse = false); + + protected: + // The mod directories this provider offers. Blocking: runs on the thread + // pool. + virtual QList scanDirectories() const = 0; + + // 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; +}; + +} // namespace px::modrim::providers diff --git a/src/core/providers/core.cpp b/src/core/providers/core.cpp new file mode 100644 index 00000000..18a3d92b --- /dev/null +++ b/src/core/providers/core.cpp @@ -0,0 +1,20 @@ +#include "core/providers/core.hpp" + +#include +#include + +namespace px::modrim::providers { + +CoreModProvider::CoreModProvider(QString gamePath) + : BaseModProvider(QStringLiteral("core"), QStringLiteral("#e67e22"), + std::move(gamePath)) {} + +QList CoreModProvider::scanDirectories() const { + const QString dataDir = QDir(path()).filePath(QStringLiteral("Data")); + if (!QFileInfo::exists(dataDir)) { + return {}; + } + return scanModDirectory(dataDir); +} + +} // namespace px::modrim::providers diff --git a/src/core/providers/core.hpp b/src/core/providers/core.hpp new file mode 100644 index 00000000..50263b48 --- /dev/null +++ b/src/core/providers/core.hpp @@ -0,0 +1,17 @@ +#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); + + protected: + QList scanDirectories() const override; +}; + +} // namespace px::modrim::providers diff --git a/src/core/providers/factory.cpp b/src/core/providers/factory.cpp new file mode 100644 index 00000000..6d60af51 --- /dev/null +++ b/src/core/providers/factory.cpp @@ -0,0 +1,31 @@ +#include "core/providers/factory.hpp" + +#include "core/providers/core.hpp" +#include "core/providers/folder.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( + QStringLiteral("local"), QStringLiteral("#2ecc71"), local, false, + pool)); + providers.push_back(std::make_unique( + QStringLiteral("downloaded"), QStringLiteral("#3498db"), local, true, + pool)); + } + if (!paths.workshop.empty()) { + providers.push_back(std::make_unique( + QStringLiteral("steam"), QStringLiteral("#3498db"), + QString::fromStdString(paths.workshop), true, 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 00000000..c159babf --- /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/folder.cpp b/src/core/providers/folder.cpp new file mode 100644 index 00000000..a11f9d3c --- /dev/null +++ b/src/core/providers/folder.cpp @@ -0,0 +1,35 @@ +#include "core/providers/folder.hpp" + +#include +#include + +#include + +namespace px::modrim::providers { + +FolderModProvider::FolderModProvider(QString providerId, QString color, + QString path, bool hasPublishedFileId, + QThreadPool* pool) + : BaseModProvider(std::move(providerId), std::move(color), std::move(path), + pool), + m_hasPublishedFileId(hasPublishedFileId) {} + +QList FolderModProvider::scanDirectories() const { + if (!QFileInfo::exists(path())) { + spdlog::debug("{} path does not exist: {}", providerId().toStdString(), + path().toStdString()); + return {}; + } + QList dirs; + for (const ModDirectory& dir : scanModDirectory(path())) { + const bool hasId = QFileInfo::exists( + QDir(dir.modPath) + .filePath(QStringLiteral("About/PublishedFileId.txt"))); + if (hasId == m_hasPublishedFileId) { + dirs.append(dir); + } + } + return dirs; +} + +} // namespace px::modrim::providers diff --git a/src/core/providers/folder.hpp b/src/core/providers/folder.hpp new file mode 100644 index 00000000..cc29d42d --- /dev/null +++ b/src/core/providers/folder.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "core/providers/base.hpp" + +namespace px::modrim::providers { + +// Mods in the direct subdirectories of one folder, restricted to those that do +// (or do not) carry a published file id. +class FolderModProvider final : public BaseModProvider { + public: + FolderModProvider(QString providerId, QString color, QString path, + bool hasPublishedFileId, QThreadPool* pool = nullptr); + + protected: + QList scanDirectories() const override; + + private: + bool m_hasPublishedFileId; +}; + +} // namespace px::modrim::providers diff --git a/src/core/providers/labels.hpp b/src/core/providers/labels.hpp new file mode 100644 index 00000000..c37cfd2d --- /dev/null +++ b/src/core/providers/labels.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace px::modrim { + +// Display name for a mod provider; an unknown provider is shown by its id. +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"); + } + return providerId; +} + +} // namespace px::modrim diff --git a/src/core/services/activation_service.cpp b/src/core/services/activation_service.cpp new file mode 100644 index 00000000..88adb807 --- /dev/null +++ b/src/core/services/activation_service.cpp @@ -0,0 +1,506 @@ +#include "core/services/activation_service.hpp" + +#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 00000000..348242eb --- /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 00000000..cf8d9fc1 --- /dev/null +++ b/src/core/services/diagnostics_service.cpp @@ -0,0 +1,328 @@ +#include "core/services/diagnostics_service.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/providers/labels.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) { + view::SidebarEntry entry; + entry.kind = kind; + entry.label = std::move(label); + entry.visibleUuids = std::move(visibleUuids); + 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); + }) {} + +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(); + } + + sort::CommunityRules 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) { + rules = std::move(*loaded); + } else { + spdlog::warn("Ignoring community rules: {}", + loaded.error().message.toStdString()); + warningRaised.notify( + QStringLiteral("Community rules are invalid and were ignored")); + } + } + } + // Applied even when empty, so rules that are no longer configured, missing, + // unreadable or empty stop influencing diagnostics and sorting. + m_checker.setCommunityRules(std::move(rules)); + m_ctx.invalidateSortInputs(); +} + +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; +} + +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); +} + +QHash +DiagnosticsService::activeModsByPid() const { + return m_checker.activeMods(); +} + +const checker::ConstraintGraph& DiagnosticsService::constraintGraph() const { + return m_checker.graph(); +} + +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(); +} + +DiagnosticsService::ActiveCounts DiagnosticsService::activeCounts() const { + const auto diagnostics = m_checker.activeModDiagnostics(); + ActiveCounts counts; + for (const auto& [uuid, diag] : diagnostics.asKeyValueRange()) { + counts.errors += diag.hasErrors() ? 1 : 0; + counts.warnings += diag.hasWarnings() ? 1 : 0; + } + return counts; +} + +int DiagnosticsService::activeErrorCount() const { + return activeCounts().errors; +} + +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.categoryDisplayName = 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; +} + +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()); +} + +QString DiagnosticsService::formatStatus() const { + const auto [errCount, warnCount] = activeCounts(); + + 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(" | ")); +} + +QList DiagnosticsService::buildSidebarEntries() { + const QHash& mods = m_ctx.allMods(); + + QSet allUuids; + QSet errors; + QSet warnings; + QMap> byProvider; + for (auto it = mods.cbegin(); it != mods.cend(); ++it) { + allUuids.insert(it.key()); + byProvider[it.value()->providerId].insert(it.key()); + if (!it.value()->valid) { + errors.insert(it.key()); + } + } + + 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 = allUuids; + inactive.subtract(active); + + using Kind = view::SidebarEntry::Kind; + QList entries{ + makeEntry(Kind::kAll, QStringLiteral("All"), std::move(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 = byProvider.cbegin(); it != byProvider.cend(); ++it) { + entries.append( + makeEntry(Kind::kProvider, providerLabel(it.key()), it.value())); + } + return entries; +} + +} // namespace px::modrim diff --git a/src/core/services/diagnostics_service.hpp b/src/core/services/diagnostics_service.hpp new file mode 100644 index 00000000..103a7c04 --- /dev/null +++ b/src/core/services/diagnostics_service.hpp @@ -0,0 +1,98 @@ +#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/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; + // Optional data that had to be skipped, such as unreadable community rules. + Event warningRaised; + Event> sidebarEntriesChanged; + + // Applies cached databases and community rules, and starts a background + // refresh of stale databases. Unreadable community rules are reported through + // `warningRaised`; rules that are missing, disabled, empty or unreadable are + // dropped so no stale rules survive. + QCoro::Task initialize(Profiler* timer = nullptr); + + // Rebuilds the checker for the context's active mods, or for `activeUuids`. + 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); + + QHash activeModsByPid() const; + const checker::ConstraintGraph& constraintGraph() 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: + struct ActiveCounts { + int errors = 0; + int warnings = 0; + }; + + ActiveCounts activeCounts() 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::NoVersionWarningDb m_noVersionWarningService; + checker::UseThisInsteadDb m_useThisInsteadService; + checker::ModChecker m_checker; + std::optional> m_dbTask; + QStringList m_lastActiveUuids; + QHash m_lastSummary; +}; + +} // namespace px::modrim diff --git a/src/core/services/game_launcher.cpp b/src/core/services/game_launcher.cpp new file mode 100644 index 00000000..0ac49979 --- /dev/null +++ b/src/core/services/game_launcher.cpp @@ -0,0 +1,134 @@ +#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 directCommand(const QString& exe, const AppConfig& cfg) { + const QString args = QString::fromStdString(cfg.launch_args); + if (currentPlatform() == Platform::kMacOs) { + auto split = splitArguments(args); + if (!split) { + return std::unexpected(split.error()); + } + return DirectCommand{.argv = QStringList{QStringLiteral("open"), exe, + QStringLiteral("--args")} + + *split, + .env = {}}; + } + return buildDirectCommand(exe, QString::fromStdString(cfg.launch_wrapper), + args); +} + +} // namespace + +GameLauncher::GameLauncher(CoreContext& ctx) : m_ctx(ctx) {} + +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(); } + +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); + + auto command = directCommand(*exe, m_ctx.config()); + if (!command) { + spdlog::warn("Invalid launch arguments: {}", + command.error().message.toStdString()); + co_return std::unexpected( + Error{QStringLiteral("Invalid launch arguments: %1") + .arg(command.error().message)}); + } + const auto& [argv, env] = *command; + + 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 00000000..8a31cc91 --- /dev/null +++ b/src/core/services/game_launcher.hpp @@ -0,0 +1,49 @@ +#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. + explicit GameLauncher(CoreContext& ctx); + + // 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; + 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 00000000..9cf8f733 --- /dev/null +++ b/src/core/services/game_process.cpp @@ -0,0 +1,487 @@ +#include "core/services/game_process.hpp" + +#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"}) { + 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) { + 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"}) { + QString candidate = dir.filePath(QString::fromLatin1(name)); + if (QFileInfo(candidate).isFile()) { + return candidate; + } + } + return std::nullopt; +} + +void ensureSteamAppid(const QString& gamePath) { + const QString targetDir = currentPlatform() == 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; +} + +namespace { + +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; +} + +} // namespace + +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 00000000..86f1ce55 --- /dev/null +++ b/src/core/services/game_process.hpp @@ -0,0 +1,128 @@ +#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); + +// 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); + +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 00000000..5296094d --- /dev/null +++ b/src/core/services/metadata_cache.cpp @@ -0,0 +1,649 @@ +#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 {}; +} + +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; +} + +} // namespace + +// Resolves symlinks component by component, consuming ".." after the link +// target has been followed, as Python's posixpath.realpath does. +QString normalizePath(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; +} + +namespace { + +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. Object-form payloads are + // re-parsed from About.xml because QJsonObject sorts their dependency keys. + 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 auto& item : value.toArray()) { + if (item.isString()) { + values.append(item.toString()); + } + } + return values; +} + +Result> deserializeMod(const QString& payload, + const QString& modPath) { + 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(); + const QJsonObject rulesData = + data.value(QLatin1StringView("about_rules")).toObject(); + const QJsonValue dependencies = + rulesData.value(QLatin1StringView("dependencies")); + if (dependencies.isObject()) { + return std::unexpected( + Error{QStringLiteral("object-form dependencies require re-parsing")}); + } + + 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); + + 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")))); + for (const auto& item : dependencies.toArray()) { + if (!item.isObject()) { + continue; + } + const QJsonObject depData = item.toObject(); + DependencyMod dep; + dep.packageId = CaseInsensitiveStr( + stringOr(depData, "package_id", QStringLiteral("invalid.mod"))); + dep.name = stringOr(depData, "name", QString()); + dep.workshopUrl = stringOr(depData, "workshop_url", QString()); + dep.alternativePackageIds = CaseInsensitiveSet(stringList( + depData.value(QLatin1StringView("alternative_package_ids")))); + mod->aboutRules.setDependency(std::move(dep)); + } + if (auto set = mod->setModPath(modPath); !set) { + return std::unexpected(set.error()); + } + return mod; +} + +// 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")); +} + +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(normalizePath(path)); + continue; + } + const QString head = slash == 0 ? QStringLiteral("/") : path.left(slash); + auto memo = resolvedParents.constFind(head); + if (memo == resolvedParents.constEnd()) { + memo = resolvedParents.insert(head, normalizePath(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, candidate.modPath); + if (!mod) { + spdlog::warn("Corrupted metadata cache payload for {}: {}", + row.modPath.toStdString(), + mod.error().message.toStdString()); + 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 00000000..47616d66 --- /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); + +// normalizePath 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 `liveModPaths`. + 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 00000000..ebcbc60c --- /dev/null +++ b/src/core/services/mod_discovery.cpp @@ -0,0 +1,62 @@ +#include "core/services/mod_discovery.hpp" + +#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 = modsConfigPath(configFolder); + 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 00000000..0cef003b --- /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 00000000..af8d9455 --- /dev/null +++ b/src/core/services/sort_service.cpp @@ -0,0 +1,140 @@ +#include "core/services/sort_service.hpp" + +#include +#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); +} + +// Everything the sort worker reads, so it never touches live service state. +struct SortInputs { + QHash activeMods; + checker::ConstraintGraph graph; + sort::CommunityRules rules; + sort::SortSettings settings; + QHash allMods; + QStringList activeUuids; +}; + +} // namespace + +SortService::SortService(CoreContext& ctx, DiagnosticsService& diagnostics) + : m_ctx(ctx), m_diagnostics(diagnostics) {} + +QCoro::Task> SortService::sortActiveMods() { + // Read before the snapshot: any later mutation bumps the revision and + // invalidates whatever the worker produces. + const quint64 revision = m_ctx.sortInputsRevision(); + const SortInputs inputs{ + .activeMods = m_diagnostics.activeModsByPid(), + .graph = m_diagnostics.constraintGraph(), + .rules = m_diagnostics.communityRules(), + .settings = m_ctx.config().sort, + .allMods = m_ctx.allMods(), + .activeUuids = m_ctx.activeUuids(), + }; + + const QList sorted = co_await QtConcurrent::run([inputs] { + ProfileSession session(QStringLiteral("sort")); + return checker::topologicalSort(inputs.activeMods, inputs.graph, + inputs.settings, &inputs.rules, + &session.timer()); + }); + if (m_ctx.sortInputsRevision() != revision) { + spdlog::warn("Sort discarded: the mod list changed while sorting"); + co_return std::nullopt; + } + co_return orderUuids(sorted, inputs.allMods, inputs.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 00000000..66c5e0ff --- /dev/null +++ b/src/core/services/sort_service.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include +#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, or to nullopt when any input changed while + // the worker ran, so a stale order never overwrites newer state. + 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 00000000..f1ccb566 --- /dev/null +++ b/src/core/services/startup_impact_service/db.cpp @@ -0,0 +1,615 @@ +#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); } + +template +Result withExistingDb(StartupImpactDb::Connection& conn, const QString& path, + T empty, Fn&& fn) { + if (!fileExists(path)) { + return empty; + } + auto opened = openConnection(conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + return fn(db); +} + +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> latestReportFrom(QSqlDatabase& db) { + 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 withExistingDb>( + m_conn, path, std::nullopt, + [](QSqlDatabase& db) { return latestReportFrom(db); }); + }); +} + +QCoro::Task>> StartupImpactDb::getAllAverages( + QString path) { + co_return co_await QtConcurrent::run( + &m_pool, + [this, path = std::move(path)]() -> Result> { + return withExistingDb>( + m_conn, path, {}, + [](QSqlDatabase& db) { 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 opened = openConnection(m_conn, path); + if (!opened) { + return std::unexpected(opened.error()); + } + QSqlDatabase db = *opened; + + auto latest = latestReportFrom(db); + if (!latest) { + return std::unexpected(latest.error()); + } + result.report = std::move(*latest); + + 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 00000000..23c4b073 --- /dev/null +++ b/src/core/services/startup_impact_service/db.hpp @@ -0,0 +1,77 @@ +#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>> 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 closeSync() 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 00000000..011b5eb7 --- /dev/null +++ b/src/core/services/startup_impact_service/labels.cpp @@ -0,0 +1,146 @@ +#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; +} + +QString stripMetricPrefix(const QString& key) { + return key.startsWith(kPrefix) ? key.mid(kPrefix.size()) : key; +} + +} // namespace + +MetricKey splitMetricKey(const QString& key) { + const QString stripped = stripMetricPrefix(key); + const qsizetype bar = stripped.indexOf(u'|'); + if (bar < 0) { + return {.name = stripped, .target = {}}; + } + return {.name = stripped.left(bar), .target = stripped.mid(bar + 1)}; +} + +QString metricLabel(const QString& key) { + const auto [base, target] = splitMetricKey(key); + 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 00000000..3355dc24 --- /dev/null +++ b/src/core/services/startup_impact_service/labels.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace px::modrim::startup_impact { + +struct MetricKey { + QString name; + QString target; +}; + +// Splits a metric key into its name and "|Target" suffix, dropping the +// "LoadingProgress.StartupImpact." prefix the game writes before every key. +MetricKey splitMetricKey(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 00000000..95e795e7 --- /dev/null +++ b/src/core/services/startup_impact_service/models.cpp @@ -0,0 +1,35 @@ +#include "core/services/startup_impact_service/models.hpp" + +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; +} + +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 { + if (packageId && !packageId->isEmpty()) { + const QString wanted = normalizePackageId(*packageId); + for (const auto& mod : mods) { + if (mod.packageId && *mod.packageId == 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 00000000..1dbfe785 --- /dev/null +++ b/src/core/services/startup_impact_service/models.hpp @@ -0,0 +1,39 @@ +#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; + QMap offThreadMetrics; + + double baseGameTime() const; + const StartupImpactMod* find(const std::optional& packageId) const; +}; + +QString normalizePackageId(QStringView packageId); + +} // 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 00000000..7631c606 --- /dev/null +++ b/src/core/services/startup_impact_service/parser.cpp @@ -0,0 +1,130 @@ +#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.offThreadMetrics = durationMap(field(root, "offThreadMetrics")); + 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 00000000..6e29592b --- /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 00000000..eeef5135 --- /dev/null +++ b/src/core/services/startup_impact_service/phases.cpp @@ -0,0 +1,93 @@ +#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 name = splitMetricKey(key).name; + + 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 00000000..1d355925 --- /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 `phaseIndex`. +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 00000000..09a0fda2 --- /dev/null +++ b/src/core/services/startup_impact_service/service.cpp @@ -0,0 +1,89 @@ +#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()); + StartupImpactReport full = *report; + 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); + full.mods.push_back(std::move(baseGame)); + } + 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::getAllAverages() { + co_return co_await m_db.getAllAverages(dbPathFor(m_ctx)); +} + +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 00000000..d4e73f95 --- /dev/null +++ b/src/core/services/startup_impact_service/service.hpp @@ -0,0 +1,44 @@ +#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>> getAllAverages(); + 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 00000000..36382691 --- /dev/null +++ b/src/core/services/update_service.cpp @@ -0,0 +1,166 @@ +#include "core/services/update_service.hpp" + +#include +#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 + +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 00000000..309b0ba0 --- /dev/null +++ b/src/core/services/update_service.hpp @@ -0,0 +1,51 @@ +#pragma once + +#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; +}; + +// 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. `releaseUrl` 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 00000000..c3ce29b2 --- /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 00000000..ed056257 --- /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 00000000..1a9ae18c --- /dev/null +++ b/src/core/sort/community_service.cpp @@ -0,0 +1,236 @@ +#include "core/sort/community_service.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/file_io.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"); + +// 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. +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) { + 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 ? std::optional(m_jsonPath) : std::nullopt; +} + +// Returns false when the archive has no rules file: there is simply nothing to +// cache. +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. + 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 false; + } + if (auto copied = copyFileReplacing(rules.next(), m_jsonPath); !copied) { + co_return std::unexpected(copied.error()); + } + loading.step(50); + } + co_return true; +} + +} // 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 00000000..6cec6a8b --- /dev/null +++ b/src/core/sort/community_service.hpp @@ -0,0 +1,38 @@ +#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 after downloading it. 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); + + 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 00000000..8335a9ab --- /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 00000000..a74e9a7b --- /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 00000000..024805d4 --- /dev/null +++ b/src/core/sort/models.hpp @@ -0,0 +1,26 @@ +#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; +}; + +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 00000000..4330b6ea --- /dev/null +++ b/src/core/sort/tiers.cpp @@ -0,0 +1,91 @@ +#include "core/sort/tiers.hpp" + +#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; + } + } +} + +// Canonical members of `ids` plus everything reachable from them in `graph`. +QSet closureWithin(const auto& ids, const QSet& canonical, + const PackageGraph& graph) { + QSet result; + for (const auto& pid : ids) { + if (canonical.contains(pid)) { + result.insert(pid); + result |= reachable(pid, graph); + } + } + return result; +} + +} // 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. + assignPresent(tiers, + closureWithin(loadLast, canonicalIds, revDeps) | + closureWithin(tierConfig.tier_3, canonicalIds, revDeps), + Tier::kTier3); + + // Tier 0 is assigned after tier 3, so a configured tier-0 mod wins over a + // tier-3 mark. + assignPresent(tiers, closureWithin(tierConfig.tier_0, canonicalIds, deps), + Tier::kTier0); + + // Only tier-2 mods move up: a tier-0 or tier-3 mark is never overridden here. + const QSet tier1Set = + closureWithin(tierConfig.tier_1, canonicalIds, deps) | + closureWithin(loadFirst, canonicalIds, deps); + for (const auto& pid : tier1Set) { + if (auto it = tiers.find(pid); it != tiers.end() && *it == Tier::kTier2) { + *it = Tier::kTier1; + } + } + + return tiers; +} + +} // namespace px::modrim::sort diff --git a/src/core/sort/tiers.hpp b/src/core/sort/tiers.hpp new file mode 100644 index 00000000..420a42fd --- /dev/null +++ b/src/core/sort/tiers.hpp @@ -0,0 +1,21 @@ +#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); + +} // namespace px::modrim::sort diff --git a/src/core/support/report.cpp b/src/core/support/report.cpp new file mode 100644 index 00000000..737bd008 --- /dev/null +++ b/src/core/support/report.cpp @@ -0,0 +1,394 @@ +#include "core/support/report.hpp" + +#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); + } + + 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 00000000..b1049b43 --- /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 00000000..04e286b2 --- /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: 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 00000000..f609f19c --- /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); + 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 00000000..40321e55 --- /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 00000000..17af14f7 --- /dev/null +++ b/src/core/version.cpp @@ -0,0 +1,7 @@ +#include "core/version.hpp" + +namespace px::modrim { + +QString appVersion() { return QStringLiteral(PX_APP_VERSION); } + +} // namespace px::modrim diff --git a/src/core/version.hpp b/src/core/version.hpp new file mode 100644 index 00000000..1243112b --- /dev/null +++ b/src/core/version.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace px::modrim { + +QString appVersion(); + +} // namespace px::modrim diff --git a/src/core/workshop/catalog.cpp b/src/core/workshop/catalog.cpp new file mode 100644 index 00000000..15c3e55e --- /dev/null +++ b/src/core/workshop/catalog.cpp @@ -0,0 +1,354 @@ +#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; +} + +} // 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(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_cacheDir(config.directory() + QStringLiteral("/workshop-cache")), + m_favourites(config), + m_downloads(&downloads), + m_installedMods(std::move(installedMods)) { + m_settings.changed.connect([this](const CatalogSettings& settings) { + changeUrl(QString::fromStdString(settings.url)); + }); + m_downloadFinishedId = m_downloads->downloadFinished.connect( + [this](const DownloadResult&) { invalidateInstalled(); }); + m_busyChangedId = m_downloads->busyChanged.connect([this](bool busy) { + if (!busy) { + resumeIdleWaiters(); + } + }); +} + +void WorkshopCatalog::setup(CoreContext& ctx) { + m_context = &ctx; + m_modsChangedId = ctx.modService().modsChanged.connect( + [this](const std::monostate&) { invalidateInstalled(); }); + m_configChangedId = ctx.configChanged().connect( + [this](const AppConfig&) { invalidateInstalled(); }); +} + +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(); + co_return co_await client().modsBatch(std::move(ids)); +} + +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); + } + } + 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; +} + +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 00000000..5af205b3 --- /dev/null +++ b/src/core/workshop/catalog.hpp @@ -0,0 +1,295 @@ +#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(ConfigService& config, DownloadManager& downloads, + InstalledModsProvider installedMods); + void setup(CoreContext& ctx) override; + ~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); + + // 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 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; + QString 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) { + 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 00000000..96b50828 --- /dev/null +++ b/src/core/workshop/client.cpp @@ -0,0 +1,336 @@ +#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); + } + } +} + +// A missing entry is not an error. +template +CatalogResult> optionalOn404(CatalogResult found) { + if (found) { + return std::optional(std::move(*found)); + } + if (found.error().status == 404) { + return std::optional{}; + } + return std::unexpected(found.error()); +} + +} // 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))); + co_return optionalOn404(std::move(found)); +} + +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))); + co_return optionalOn404(std::move(found)); +} + +QCoro::Task>> CatalogClient::modsBatch( + QStringList ids) { + std::vector items; + 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()); + } + items.insert(items.end(), std::make_move_iterator(batch->items.begin()), + std::make_move_iterator(batch->items.end())); + } + co_return items; +} + +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 00000000..d3ed4f7b --- /dev/null +++ b/src/core/workshop/client.hpp @@ -0,0 +1,68 @@ +#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: + 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 00000000..ae318a92 --- /dev/null +++ b/src/core/workshop/favourites.cpp @@ -0,0 +1,55 @@ +#include "core/workshop/favourites.hpp" + +#include +#include + +namespace px::modrim::workshop { + +FavouriteCollections::FavouriteCollections(ConfigService& service) + : m_config(service, QStringLiteral("workshop_favourites")) {} + +bool FavouriteCollections::contains(const std::string& collectionId) const { + return std::ranges::any_of( + m_config.value().collections, + [&](const CatalogCollection& item) { return item.id == 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_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 00000000..5b96a73b --- /dev/null +++ b/src/core/workshop/favourites.hpp @@ -0,0 +1,47 @@ +#pragma once + +#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: + 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; +}; + +} // namespace px::modrim::workshop diff --git a/src/core/workshop/types.hpp b/src/core/workshop/types.hpp new file mode 100644 index 00000000..6cef94e2 --- /dev/null +++ b/src/core/workshop/types.hpp @@ -0,0 +1,272 @@ +#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{}; + 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 00000000..10026f39 --- /dev/null +++ b/src/core/xml.cpp @@ -0,0 +1,103 @@ +#include "core/xml.hpp" + +#include +#include +#include +#include + +#include "core/file_io.hpp" + +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; +} + +} // 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)}); + } + auto contents = readFileBytes(path); + if (!contents) { + return std::unexpected( + Error{QStringLiteral("Failed to read XML file %1: %2") + .arg(path, contents.error().message)}); + } + // Keep whitespace text, comments, and PIs so .text never crosses a node. + const auto parsed = + doc.load_buffer(contents->data(), 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; +} + +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()); +} + +} // namespace px::modrim diff --git a/src/core/xml.hpp b/src/core/xml.hpp new file mode 100644 index 00000000..04cafa6c --- /dev/null +++ b/src/core/xml.hpp @@ -0,0 +1,17 @@ +#pragma once + +#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); +glz::json_t elementToJson(pugi::xml_node element); +Result xmlFileToJson(const QString& path); + +} // namespace px::modrim diff --git a/src/pxmodrim/__init__.py b/src/pxmodrim/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pxmodrim/__main__.py b/src/pxmodrim/__main__.py deleted file mode 100644 index 54f585f7..00000000 --- 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 a7be82a7..00000000 --- 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 abb57f7f..00000000 --- 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 9d48db4f..00000000 --- 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 07df9ef7..00000000 --- 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 eb6c0307..00000000 --- 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 a8ba8c25..00000000 --- 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 f36f2755..00000000 --- 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 98249a0b..00000000 --- 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 6e04ad02..00000000 --- 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 1ca65745..00000000 --- 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 ca8483e0..00000000 --- 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 ef88d586..00000000 --- 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 6f66a34d..00000000 --- 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 e8d7fe4b..00000000 --- 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 615ff3af..00000000 --- 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 7e4f113f..00000000 --- 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 84ecc246..00000000 --- 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 661e8ad8..00000000 --- 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 b9f02e66..00000000 --- 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 fcc19c0c..00000000 --- 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 a65ee363..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/core/models/metadata/__init__.py b/src/pxmodrim/core/models/metadata/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pxmodrim/core/models/metadata/parsing.py b/src/pxmodrim/core/models/metadata/parsing.py deleted file mode 100644 index 23b9844f..00000000 --- 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 b37b78b2..00000000 --- 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 9d48db4f..00000000 --- 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 50f19541..00000000 --- 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 731e2dc3..00000000 --- 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 935d324b..00000000 --- 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 92db58d5..00000000 --- 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 e209a623..00000000 --- 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 03734ccd..00000000 --- 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 4fc888f0..00000000 --- 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 db3703d4..00000000 --- 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 7aa101e9..00000000 --- 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 cdffd938..00000000 --- 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 50c8f167..00000000 --- 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 b86f98b4..00000000 --- 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 a6807687..00000000 --- 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 df0c4ae3..00000000 --- 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 71480747..00000000 --- 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 9fa533d0..00000000 --- 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 35ef7ed6..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/core/services/activation_service.py b/src/pxmodrim/core/services/activation_service.py deleted file mode 100644 index 66324b3d..00000000 --- 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 eecb8135..00000000 --- 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 03a7eecc..00000000 --- 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 3750cfbf..00000000 --- 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 bdc189f5..00000000 --- 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 e6c005d4..00000000 --- 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 8da10b33..00000000 --- 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 602dc67a..00000000 --- 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 cf49ef0b..00000000 --- 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 1bf79c85..00000000 --- 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 46e8ab88..00000000 --- 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 4a0061a9..00000000 --- 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 581270f9..00000000 --- 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 d4a46e8a..00000000 --- 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 6e465871..00000000 --- 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 6dbe6ef8..00000000 --- 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 d30d9442..00000000 --- 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 355e2901..00000000 --- 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 45e0b5e0..00000000 --- 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 fd7cdb0c..00000000 --- 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 ee79406f..00000000 --- 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 3b94d9b6..00000000 --- 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 e395e1c3..00000000 --- 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 6b48ea65..00000000 --- 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 5ff50629..00000000 --- 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 b18f713f..00000000 --- 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 730c9b32..00000000 --- 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 d1ca96e8..00000000 --- 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 a494aafa..00000000 --- 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 dff168c7..00000000 --- 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 45228906..00000000 --- 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 dfffae85..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/ui/assets/__init__.py b/src/pxmodrim/ui/assets/__init__.py deleted file mode 100644 index 9d48db4f..00000000 --- a/src/pxmodrim/ui/assets/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from __future__ import annotations diff --git a/src/pxmodrim/ui/components/IconRail.qml b/src/pxmodrim/ui/components/IconRail.qml deleted file mode 100644 index b2cdfcd1..00000000 --- a/src/pxmodrim/ui/components/IconRail.qml +++ /dev/null @@ -1,246 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import "controls" - -Rectangle { - id: root - color: Theme.elevate2 - - property int currentIndex: 0 - property bool collapsed: root.width < Theme.railCollapseWidth - property var tabModel: railModel - - signal tabSelected(int index) - signal tabHovered(int index) - signal settingsRequested() - signal helpActionRequested(string actionId) - - component RailButton: Item { - id: button - property string iconName - property string label - property bool collapsed - signal clicked() - - width: parent ? parent.width : 0 - height: 44 - - Rectangle { - anchors.fill: parent - anchors.margins: 4 - radius: Theme.radiusMd - color: area.containsMouse ? Theme.elevate3 : "transparent" - - RowLayout { - anchors.fill: parent - anchors.leftMargin: button.collapsed ? 0 : 12 - anchors.rightMargin: button.collapsed ? 0 : 8 - spacing: 10 - - Item { - Layout.preferredWidth: 18 - Layout.preferredHeight: 18 - Layout.alignment: button.collapsed ? Qt.AlignCenter : Qt.AlignVCenter - Layout.fillWidth: button.collapsed - - Image { - anchors.centerIn: parent - source: "image://icons/" + button.iconName + "?color=" + encodeURIComponent(Theme.textMuted) - sourceSize.width: 18; sourceSize.height: 18 - fillMode: Image.PreserveAspectFit - } - } - - Text { - visible: !button.collapsed - Layout.fillWidth: true - text: button.label - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - elide: Text.ElideRight - } - } - - MouseArea { - id: area - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: button.clicked() - } - - Loader { - active: button.collapsed && area.containsMouse - sourceComponent: PxToolTip { - visible: true - text: button.label - } - } - } - - Accessible.role: Accessible.Button - Accessible.name: button.label - } - - ColumnLayout { - anchors.fill: parent - spacing: 0 - - ListView { - id: listView - objectName: "railList" - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: 4 - model: root.tabModel - currentIndex: root.currentIndex - - delegate: Item { - id: delegate - width: listView.width - height: 44 - property bool active: index === listView.currentIndex - - Rectangle { - id: bg - anchors.fill: parent - anchors.margins: 4 - radius: Theme.radiusMd - color: delegate.active - ? Theme.elevate4 - : (hoverArea.containsMouse ? Theme.elevate3 : "transparent") - - Rectangle { - visible: delegate.active - width: 3 - height: 18 - radius: Theme.radiusXs - color: Theme.primary - anchors.left: parent.left - anchors.leftMargin: 3 - anchors.verticalCenter: parent.verticalCenter - } - - RowLayout { - anchors.fill: parent - anchors.leftMargin: root.collapsed ? 0 : 12 - anchors.rightMargin: 8 - spacing: 10 - visible: !root.collapsed - - Item { - width: 18; height: 18 - Layout.alignment: Qt.AlignVCenter - - Image { - anchors.centerIn: parent - source: "image://icons/" + (modelData.icon || "") + "?color=" + - encodeURIComponent(delegate.active ? Theme.primary : Theme.textMuted) - sourceSize.width: 18; sourceSize.height: 18 - fillMode: Image.PreserveAspectFit - opacity: 1 - } - } - - Text { - id: labelText - Layout.fillWidth: true - text: modelData.label || "" - color: delegate.active ? Theme.primary : Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - font.weight: delegate.active ? Font.Medium : Font.Normal - elide: Text.ElideRight - horizontalAlignment: Text.AlignLeft - opacity: 1 - } - } - - Item { - anchors.fill: parent - visible: root.collapsed - - Image { - id: collapsedIcon - width: 18; height: 18 - anchors.centerIn: parent - source: "image://icons/" + (modelData.icon || "") + "?color=" + - encodeURIComponent(delegate.active ? Theme.primary : Theme.textMuted) - sourceSize.width: 18; sourceSize.height: 18 - fillMode: Image.PreserveAspectFit - opacity: 1 - } - } - - MouseArea { - id: hoverArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: root.tabHovered(index) - onClicked: { - root.currentIndex = index - root.tabSelected(index) - } - } - - Loader { - active: root.collapsed && hoverArea.containsMouse - sourceComponent: PxToolTip { - visible: true - text: modelData.label || "" - } - } - - Behavior on color { - ColorAnimation { duration: 120; easing.type: Easing.OutCubic } - } - } - } - } - Rectangle { - Layout.fillWidth: true - Layout.preferredHeight: 1 - color: Theme.border - } - - Column { - Layout.fillWidth: true - Layout.topMargin: 4 - Layout.bottomMargin: 4 - - RailButton { - iconName: "settings" - label: "Settings" - collapsed: root.collapsed - onClicked: root.settingsRequested() - } - - RailButton { - id: helpButton - iconName: "help" - label: "Help" - collapsed: root.collapsed - onClicked: helpMenu.popup(helpButton, helpButton.width, helpButton.height - helpMenu.implicitHeight) - } - } - } - - PxMenu { - id: helpMenu - objectName: "helpMenu" - - Repeater { - model: railHelpEntries - - PxMenuItem { - required property var modelData - text: modelData.label - onTriggered: root.helpActionRequested(modelData.id) - } - } - } -} diff --git a/src/pxmodrim/ui/components/__init__.py b/src/pxmodrim/ui/components/__init__.py deleted file mode 100644 index e6da7800..00000000 --- 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 45fd463a..00000000 --- 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 ec29ca17..00000000 --- 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 d7213bbe..00000000 --- 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 cd4954ec..00000000 --- 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 45dc80d1..00000000 --- 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 e289c722..00000000 --- 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 8a4b627e..00000000 --- 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 d551c037..00000000 --- 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 39b69ca3..00000000 --- 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 faf8792a..00000000 --- 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 c0b23e2f..00000000 --- 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 065e2a8f..00000000 --- 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 81be3b56..00000000 --- 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 2ee539e5..00000000 --- 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 e5723f92..00000000 --- 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 9222fdf9..00000000 --- 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 150fa932..00000000 --- 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 44213cd0..00000000 --- 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 2c23e052..00000000 --- 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 05cd4024..00000000 --- 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 7f037197..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/ui/models/impact.py b/src/pxmodrim/ui/models/impact.py deleted file mode 100644 index b030c2e2..00000000 --- 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 0cb7571f..00000000 --- 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 532c8759..00000000 --- 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 9b1fd634..00000000 --- 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 77822a98..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/ui/panels/about_panel.py b/src/pxmodrim/ui/panels/about_panel.py deleted file mode 100644 index 69256374..00000000 --- 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 3eb5c288..00000000 --- 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 9f57671b..00000000 --- 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 545b8133..00000000 --- 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 026b3c5a..00000000 --- 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 cece2245..00000000 --- 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 35c58a88..00000000 --- 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 1eda5b3b..00000000 --- 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 bd6b1073..00000000 --- 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 174023eb..00000000 --- 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 a7ff88e2..00000000 --- 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 02d2c27d..00000000 --- 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 ab1c8e0f..00000000 --- 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 808d2e2a..00000000 --- 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 64837330..00000000 --- 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 3b8aece0..00000000 --- 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 456d2c88..00000000 --- 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 e5b4340f..00000000 --- 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 6fcf6e00..00000000 --- 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 f9ef7f9c..00000000 --- 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 54624403..00000000 --- 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 42109b6b..00000000 --- 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 c8ccf4e3..00000000 --- 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 3d72aa15..00000000 --- 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 e5ee0d4d..00000000 --- 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 ab35a83d..00000000 --- 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 97af8703..00000000 --- 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 8ed32df7..00000000 --- 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 153b74af..00000000 --- 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 8fd04a99..00000000 --- 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 051e86a9..00000000 --- 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 3bb81673..00000000 --- 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 0d157998..00000000 --- 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 a8023a9d..00000000 --- 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 dd094dd7..00000000 --- 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 2d296488..00000000 --- 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 03dfad87..00000000 --- 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 03610e5c..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/ui/theme/constants.py b/src/pxmodrim/ui/theme/constants.py deleted file mode 100644 index c5dd91c2..00000000 --- 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 a486711f..00000000 --- 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 0d6f7056..00000000 --- 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 0eefe238..00000000 --- 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 fc7e15f7..00000000 --- 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 0bf0c0b0..00000000 --- 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 fcc94f5e..00000000 --- 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 e69de29b..00000000 diff --git a/src/pxmodrim/ui/window/actions.py b/src/pxmodrim/ui/window/actions.py deleted file mode 100644 index 83504386..00000000 --- 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 b1fd4294..00000000 --- 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 8a83aa60..00000000 --- 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 00000000..ab294b4d --- /dev/null +++ b/src/ui/CMakeLists.txt @@ -0,0 +1,51 @@ +# 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}$") + list(APPEND PX_UI_RESOURCES ${CMAKE_CURRENT_SOURCE_DIR}/${PX_UI_WEB_QML}) +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 98% rename from src/pxmodrim/ui/plugins/workshop/CatalogCard.qml rename to src/ui/CatalogCard.qml index 825aea70..8a1304d7 100644 --- a/src/pxmodrim/ui/plugins/workshop/CatalogCard.qml +++ b/src/ui/CatalogCard.qml @@ -1,7 +1,6 @@ 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 9aa0df2b..c67c66b1 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 7fb0a9e9..5e7275f0 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 bdaab603..185beb05 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 60% rename from src/pxmodrim/ui/components/controls/DiagnosticBadge.qml rename to src/ui/DiagnosticBadge.qml index 9b4ea510..f651456e 100644 --- a/src/pxmodrim/ui/components/controls/DiagnosticBadge.qml +++ b/src/ui/DiagnosticBadge.qml @@ -1,16 +1,15 @@ import QtQuick -// "error" | "warning" glyph with an optional count and hover tooltip. +// "error" | "warning" glyph with an optional hover tooltip. Item { id: root property string level: "error" - property int count: 0 property string tooltip: "" readonly property color tint: level === "error" ? Theme.danger : Theme.warning - implicitWidth: countText.visible ? 16 + 3 + countText.implicitWidth : 16 + implicitWidth: 16 implicitHeight: 16 Accessible.role: Accessible.Graphic @@ -24,19 +23,6 @@ Item { source: "image://icons/" + (root.level === "error" ? "error" : "warning") + "?color=" + encodeURIComponent(root.tint) } - Text { - id: countText - - visible: root.count > 0 - x: 19 - anchors.verticalCenter: parent.verticalCenter - text: root.count - color: root.tint - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeSm - font.weight: Font.DemiBold - } - HoverHandler { id: hover cursorShape: Qt.PointingHandCursor diff --git a/src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml b/src/ui/DownloadSidebar.qml similarity index 95% rename from src/pxmodrim/ui/plugins/steam_workshop/DownloadSidebar.qml rename to src/ui/DownloadSidebar.qml index 5c9c2673..7da32e1b 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 @@ -14,7 +14,6 @@ Rectangle { spacing: 0 Text { - id: titleLabel text: "Download queue" + (listView.count > 0 ? " (" + listView.count + ")" : "") color: Theme.textMain font.family: Theme.fontFamily @@ -44,9 +43,7 @@ Rectangle { spacing: 2 model: downloadQueueModel - ScrollBar.vertical: PxScrollBar { - policy: ScrollBar.AsNeeded - } + ScrollBar.vertical: PxScrollBar {} delegate: Rectangle { width: listView.width @@ -118,7 +115,6 @@ Rectangle { } PxButton { - id: clearButton Layout.fillWidth: true variant: "danger" text: "Clear queue" @@ -127,7 +123,6 @@ Rectangle { } PxButton { - id: downloadButton Layout.fillWidth: true Layout.topMargin: visible ? 4 : 0 variant: "success" @@ -138,7 +133,6 @@ Rectangle { } PxButton { - id: stopButton Layout.fillWidth: true Layout.topMargin: 4 variant: "dangerSolid" 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 635aacfe..7da13409 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 @@ -221,7 +221,7 @@ Rectangle { spacing: 2 model: downloadsModel visible: count > 0 - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: PxScrollBar {} delegate: Rectangle { width: listView.width 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 92% rename from src/pxmodrim/ui/plugins/organizer/ErrorBanner.qml rename to src/ui/ErrorBanner.qml index bc01f916..616ddc71 100644 --- a/src/pxmodrim/ui/plugins/organizer/ErrorBanner.qml +++ b/src/ui/ErrorBanner.qml @@ -1,7 +1,7 @@ import QtQuick import QtQuick.Layouts -// Inline error strip; hidden while `message` is empty. +// Inline error strip; hidden while `text` is empty. Rectangle { id: root diff --git a/src/pxmodrim/ui/components/FilterSidebar.qml b/src/ui/FilterSidebar.qml similarity index 96% rename from src/pxmodrim/ui/components/FilterSidebar.qml rename to src/ui/FilterSidebar.qml index 47c90c68..8f243840 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. @@ -9,7 +9,6 @@ Rectangle { id: root property var model: null - property alias currentIndex: filters.currentIndex signal activated(int index) color: Theme.elevate2 @@ -48,7 +47,7 @@ Rectangle { font.letterSpacing: 0.5 } } - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: PxScrollBar {} delegate: Rectangle { id: row 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 266e6f02..12e364f7 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 @@ -102,7 +102,6 @@ Rectangle { Rectangle { id: bubble - property alias text: bubbleText.text function show(message) { bubbleText.text = message opacity = 1 @@ -231,7 +230,7 @@ Rectangle { Row { Layout.alignment: Qt.AlignRight spacing: 6 - visible: root.controller.is_frameless + visible: root.controller.isFrameless PxButton { variant: "ghost" @@ -363,6 +362,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/ui/IconRail.qml b/src/ui/IconRail.qml new file mode 100644 index 00000000..47c0713a --- /dev/null +++ b/src/ui/IconRail.qml @@ -0,0 +1,176 @@ +import QtQuick +import QtQuick.Layouts +import PxModRim + +Rectangle { + id: root + color: Theme.elevate2 + + property int currentIndex: 0 + property bool collapsed: root.width < Theme.railCollapseWidth + + signal tabSelected(int index) + signal tabHovered(int index) + signal settingsRequested() + signal helpActionRequested(string actionId) + + component RailItem: Item { + id: item + property string iconName + property string label + property bool collapsed + property bool active: false + signal clicked() + signal entered() + + width: parent ? parent.width : 0 + height: 44 + + Rectangle { + anchors.fill: parent + anchors.margins: 4 + radius: Theme.radiusMd + color: item.active + ? Theme.elevate4 + : (area.containsMouse ? Theme.elevate3 : "transparent") + + Behavior on color { + ColorAnimation { duration: 120; easing.type: Easing.OutCubic } + } + + Rectangle { + visible: item.active + width: 3 + height: 18 + radius: Theme.radiusXs + color: Theme.primary + anchors.left: parent.left + anchors.leftMargin: 3 + anchors.verticalCenter: parent.verticalCenter + } + + RowLayout { + anchors.fill: parent + anchors.leftMargin: item.collapsed ? 0 : 12 + anchors.rightMargin: item.collapsed ? 0 : 8 + spacing: 10 + + Item { + Layout.preferredWidth: 18 + Layout.preferredHeight: 18 + Layout.alignment: item.collapsed ? Qt.AlignCenter : Qt.AlignVCenter + Layout.fillWidth: item.collapsed + + Image { + anchors.centerIn: parent + source: "image://icons/" + item.iconName + "?color=" + + encodeURIComponent(item.active ? Theme.primary : Theme.textMuted) + sourceSize.width: 18; sourceSize.height: 18 + fillMode: Image.PreserveAspectFit + } + } + + Text { + visible: !item.collapsed + Layout.fillWidth: true + text: item.label + color: item.active ? Theme.primary : Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + font.weight: item.active ? Font.Medium : Font.Normal + elide: Text.ElideRight + } + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: item.entered() + onClicked: item.clicked() + } + + Loader { + active: item.collapsed && area.containsMouse + sourceComponent: PxToolTip { + visible: true + text: item.label + } + } + } + + Accessible.role: Accessible.Button + Accessible.name: item.label + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + ListView { + id: listView + objectName: "railList" + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 4 + model: railModel + currentIndex: root.currentIndex + + delegate: RailItem { + width: listView.width + iconName: modelData.icon || "" + label: modelData.label || "" + collapsed: root.collapsed + active: index === listView.currentIndex + onEntered: root.tabHovered(index) + onClicked: { + root.currentIndex = index + root.tabSelected(index) + } + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Theme.border + } + + Column { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.bottomMargin: 4 + + RailItem { + iconName: "settings" + label: "Settings" + collapsed: root.collapsed + onClicked: root.settingsRequested() + } + + RailItem { + id: helpButton + iconName: "help" + label: "Help" + collapsed: root.collapsed + onClicked: helpMenu.popup(helpButton, helpButton.width, helpButton.height - helpMenu.implicitHeight) + } + } + } + + PxMenu { + id: helpMenu + objectName: "helpMenu" + + Repeater { + model: railHelpEntries + + PxMenuItem { + required property var modelData + text: modelData.label + onTriggered: root.helpActionRequested(modelData.id) + } + } + } +} 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 95% rename from src/pxmodrim/ui/panels/ModInfo.qml rename to src/ui/ModInfo.qml index 2f27af8e..f4dd6632 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 @@ -17,6 +17,12 @@ Rectangle { property bool updatingThis: false property bool neededExpanded: false + component IconAction: PxButton { + implicitHeight: 24 + implicitWidth: 24 + variant: "ghost" + } + signal openFolder() signal openUrl() signal updateMods() @@ -120,7 +126,7 @@ Rectangle { clip: true boundsBehavior: Flickable.StopAtBounds - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: PxScrollBar {} ColumnLayout { id: page @@ -457,31 +463,22 @@ Rectangle { spacing: 4 CardLabel { text: "Details" } - PxButton { + IconAction { visible: root.hasInfo && root.info.canOpenFolder - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" iconName: "folder" ToolTip.text: "Open mod folder" onClicked: root.openFolder() } - PxButton { + IconAction { visible: root.hasInfo && root.info.url.length > 0 - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" iconName: "link" ToolTip.text: root.hasInfo ? "Open mod URL: " + root.info.url : "Open mod URL" onClicked: root.openUrl() } - PxButton { + IconAction { visible: root.hasInfo && root.info.canUpdate && !root.updatingThis enabled: !root.downloadsBusy - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" iconName: "download" ToolTip.text: root.downloadsBusy ? "Download in progress" : "Update from " + (root.hasInfo ? root.info.updateSource : "source") onClicked: root.updateMods() @@ -520,13 +517,8 @@ Rectangle { font.pixelSize: Theme.fontSizeMd wrapMode: Text.WrapAnywhere } - PxButton { + IconAction { visible: modelData.copy === true - Layout.preferredHeight: 24 - Layout.preferredWidth: 24 - implicitHeight: 24 - implicitWidth: 24 - variant: "ghost" iconName: "copy" ToolTip.text: "Copy" onClicked: root.copyText(modelData.value) diff --git a/src/pxmodrim/ui/panels/ModList.qml b/src/ui/ModList.qml similarity index 94% rename from src/pxmodrim/ui/panels/ModList.qml rename to src/ui/ModList.qml index c60af002..e1dfe186 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 @@ -9,6 +9,8 @@ Rectangle { property bool keyboardActive: modListHasFocus && listView.activeFocus readonly property bool compactMode: Boolean(modListPanel && modListPanel.compactMode) + readonly property int rowHeight: compactMode ? 32 : 52 + readonly property int rowStride: compactMode ? 34 : 54 ListView { id: listView objectName: "listView" @@ -36,8 +38,6 @@ Rectangle { property string currentUuid: "" ScrollBar.vertical: PxScrollBar { - id: scrollBar - policy: ScrollBar.AsNeeded active: true } section.property: "sectionName" @@ -84,7 +84,6 @@ Rectangle { } EmptyState { - id: emptyStateText objectName: "emptyStateText" anchors.fill: parent visible: listView.count === 0 @@ -124,7 +123,7 @@ Rectangle { objectName: "dragProxy" visible: false width: listView.width - height: root.compactMode ? 32 : 52 + height: root.rowHeight radius: Theme.radiusMd color: Theme.elevate3 opacity: 0.92 @@ -183,7 +182,7 @@ Rectangle { if (targetItem) { return targetItem.y - listView.contentY } - var rowH = root.compactMode ? 34 : 54 + var rowH = root.rowStride return Math.max(0, Math.min( listView.height - height, listView.dragTargetIndex * rowH - listView.contentY @@ -201,7 +200,7 @@ Rectangle { Rectangle { id: delegateRect width: listView.width - height: root.compactMode ? 32 : 52 + height: root.rowHeight radius: Theme.radiusMd color: { @@ -587,13 +586,7 @@ Rectangle { onActivated: navigate(true, 1) } Shortcut { - sequence: "Return" - context: Qt.WindowShortcut - enabled: root.keyboardActive - onActivated: modListPanel.toggleChecked(listView.selectedIndices) - } - Shortcut { - sequence: "Space" + sequences: ["Return", "Space"] context: Qt.WindowShortcut enabled: root.keyboardActive onActivated: modListPanel.toggleChecked(listView.selectedIndices) @@ -615,12 +608,24 @@ Rectangle { listView.anchorUuid = uuids.length > 0 ? uuids[uuids.length - 1] : "" listView.currentIndex = listView.count - 1 listView.currentUuid = listView.anchorUuid - modListPanel.selectionChanged(uuids) if (listView.currentUuid) modListPanel.modSelected(listView.currentUuid) } } + function selectRange(index) { + var start = Math.min(listView.anchorIndex, index) + var end = Math.max(listView.anchorIndex, index) + var range = [] + var rangeUuids = [] + for (var row = start; row <= end; ++row) { + range.push(row) + rangeUuids.push(modListPanel.uuidAt(row)) + } + listView.selectedIndices = range + listView.selectedUuids = rangeUuids + } + function selectRow(index, uuid, modifiers) { listView.forceActiveFocus() if (modifiers & Qt.ControlModifier) { @@ -639,16 +644,7 @@ Rectangle { listView.anchorIndex = index listView.anchorUuid = uuid } else if (modifiers & Qt.ShiftModifier && listView.anchorIndex >= 0) { - var start = Math.min(listView.anchorIndex, index) - var end = Math.max(listView.anchorIndex, index) - var range = [] - var rangeUuids = [] - for (var row = start; row <= end; ++row) { - range.push(row) - rangeUuids.push(modListPanel.uuidAt(row)) - } - listView.selectedIndices = range - listView.selectedUuids = rangeUuids + selectRange(index) } else { listView.selectedIndices = [index] listView.selectedUuids = [uuid] @@ -658,7 +654,6 @@ Rectangle { listView.currentIndex = index listView.currentUuid = uuid - modListPanel.selectionChanged(listView.selectedUuids) modListPanel.modSelected(uuid) } @@ -678,16 +673,7 @@ Rectangle { listView.anchorIndex = newIndex listView.anchorUuid = uuid } - var start = Math.min(listView.anchorIndex, newIndex) - var end = Math.max(listView.anchorIndex, newIndex) - var range = [] - var rangeUuids = [] - for (var row = start; row <= end; ++row) { - range.push(row) - rangeUuids.push(modListPanel.uuidAt(row)) - } - listView.selectedIndices = range - listView.selectedUuids = rangeUuids + selectRange(newIndex) } else { listView.selectedIndices = [newIndex] listView.selectedUuids = [uuid] @@ -698,7 +684,6 @@ Rectangle { listView.currentIndex = newIndex listView.currentUuid = uuid listView.positionViewAtIndex(newIndex, ListView.Contain) - modListPanel.selectionChanged(listView.selectedUuids) modListPanel.modSelected(uuid) } @@ -726,7 +711,6 @@ Rectangle { listView.currentUuid = "" listView.anchorIndex = -1 listView.anchorUuid = "" - modListPanel.selectionChanged([]) modListPanel.modSelected("") return } @@ -738,7 +722,6 @@ Rectangle { listView.anchorUuid = listView.currentUuid } listView.anchorIndex = anchorRow - modListPanel.selectionChanged(uuids) } function targetIndexForPoint(x, y) { @@ -758,7 +741,7 @@ Rectangle { idx = listView.indexAt(x, y - 16) if (idx >= 0) return Math.max(0, Math.min(idx, maxActiveTarget)) - var rowH = root.compactMode ? 34 : 54 + var rowH = root.rowStride return Math.max(0, Math.min(Math.floor(y / rowH), maxActiveTarget)) } } 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 5be102d3..2693fa7c 100644 --- a/src/pxmodrim/ui/plugins/workshop/ModRow.qml +++ b/src/ui/ModRow.qml @@ -1,7 +1,6 @@ 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 5005d34c..279a0300 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 = "" } } @@ -123,7 +126,7 @@ Rectangle { boundsBehavior: Flickable.StopAtBounds reuseItems: false focus: true - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: PxScrollBar {} Keys.onPressed: function(event) { var row = root.activeRow() if (event.key === Qt.Key_Down || event.key === Qt.Key_Up) { @@ -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 72% rename from src/pxmodrim/ui/components/controls/PxMenuItem.qml rename to src/ui/PxMenuItem.qml index 974df033..56e42797 100644 --- a/src/pxmodrim/ui/components/controls/PxMenuItem.qml +++ b/src/ui/PxMenuItem.qml @@ -27,22 +27,8 @@ MenuItem { opacity: control.enabled ? 1 : Theme.disabledOpacity } - arrow: Image { - x: control.width - width - control.rightPadding - y: (control.height - height) / 2 - visible: control.subMenu !== null - width: 12 - height: 12 - sourceSize.width: 12 - sourceSize.height: 12 - rotation: -90 - opacity: control.enabled ? 1 : Theme.disabledOpacity - source: visible ? "image://icons/chevron-down?color=" + encodeURIComponent(Theme.textDim) : "" - } - contentItem: Text { leftPadding: control.checkable ? control.indicator.width + control.spacing : 0 - rightPadding: control.subMenu !== null ? control.arrow.width + control.spacing : 0 text: control.text font: control.font color: Theme.textMain 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 92% rename from src/pxmodrim/ui/plugins/organizer/RuleEditor.qml rename to src/ui/RuleEditor.qml index f18ba7e7..9d4246e5 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 @@ -74,37 +74,22 @@ FocusScope { return true } - function findFieldIndex(val) { - for (var i = 0; i < fieldOptions.length; ++i) { - if (fieldOptions[i].value === val) - return i - } - return 0 - } - - function findOpIndex(val) { - for (var i = 0; i < opOptions.length; ++i) { - if (opOptions[i].value === val) - return i - } - return 0 + function findFolderIndex(fid) { + return Math.max(0, ruleFolders.findIndex(folder => folder.id === fid)) } - function findFolderIndex(fid) { - for (var i = 0; i < ruleFolders.length; ++i) { - if (ruleFolders[i].id === fid) - return i - } - return 0 + function markDraftDirty() { + if (!loadingDraft) + draftRevision++ } ListModel { id: draftModel - onDataChanged: { if (!root.loadingDraft) root.draftRevision++ } - onRowsInserted: { if (!root.loadingDraft) root.draftRevision++ } - onRowsRemoved: { if (!root.loadingDraft) root.draftRevision++ } - onRowsMoved: { if (!root.loadingDraft) root.draftRevision++ } - onModelReset: { if (!root.loadingDraft) root.draftRevision++ } + onDataChanged: root.markDraftDirty() + onRowsInserted: root.markDraftDirty() + onRowsRemoved: root.markDraftDirty() + onRowsMoved: root.markDraftDirty() + onModelReset: root.markDraftDirty() } function loadDraft() { @@ -126,9 +111,7 @@ FocusScope { } onRuleRowsChanged: { - if (!opened && !draftMatches(ruleRows)) - loadDraft() - else if (opened && draftMatches(loadedRows) && !draftMatches(ruleRows)) + if ((!opened || draftMatches(loadedRows)) && !draftMatches(ruleRows)) loadDraft() refreshDirty() schedulePreview() @@ -336,7 +319,7 @@ FocusScope { model: root.fieldOptions textRole: "text" valueRole: "value" - currentIndex: root.findFieldIndex(rowItem.field) + currentIndex: Math.max(0, indexOfValue(rowItem.field)) onActivated: draftModel.setProperty(rowItem.index, "field", currentValue) } @@ -346,7 +329,7 @@ FocusScope { model: root.opOptions textRole: "text" valueRole: "value" - currentIndex: root.findOpIndex(rowItem.op) + currentIndex: Math.max(0, indexOfValue(rowItem.op)) onActivated: draftModel.setProperty(rowItem.index, "op", currentValue) } diff --git a/src/pxmodrim/ui/panels/Settings.qml b/src/ui/Settings.qml similarity index 86% rename from src/pxmodrim/ui/panels/Settings.qml rename to src/ui/Settings.qml index b5e9a19b..d826bec6 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 @@ -19,6 +19,7 @@ Rectangle { workshop: workshop.text.trim(), config: configFolder.text.trim(), compact: compact.checked, + uiScale: uiScale.currentValue, launchArgs: launchArgs.text.trim(), launchWrapper: launchWrapper.text.trim(), afterLaunch: afterLaunch.currentValue, @@ -45,6 +46,20 @@ Rectangle { } } + component FormLabel: Text { + Layout.preferredWidth: 150 + color: Theme.textMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeMd + } + component FormHint: Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + color: Theme.textDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeXs + } + component Group: Rectangle { default property alias content: body.data property string title: "" @@ -84,13 +99,7 @@ Rectangle { Layout.fillWidth: true spacing: 8 - Text { - Layout.preferredWidth: 150 - text: parent.label - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } + FormLabel { text: parent.label } PxTextField { id: field Layout.fillWidth: true @@ -205,13 +214,7 @@ Rectangle { RowLayout { Layout.fillWidth: true spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Arguments" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } + FormLabel { text: "Arguments" } PxTextField { id: launchArgs Layout.fillWidth: true @@ -221,24 +224,11 @@ Rectangle { Accessible.name: "Game launch arguments" } } - Text { - Layout.fillWidth: true - text: "Passed to the game. With the Steam strategy they go through steam://run." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } + FormHint { text: "Passed to the game. With the Steam strategy they go through steam://run." } RowLayout { Layout.fillWidth: true spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "Wrapper" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } + FormLabel { text: "Wrapper" } PxTextField { id: launchWrapper Layout.fillWidth: true @@ -248,24 +238,11 @@ Rectangle { Accessible.name: "Launch wrapper command" } } - Text { - Layout.fillWidth: true - text: "Direct launch only. Leading KEY=VALUE tokens set environment variables; %command% marks where the game goes, otherwise the wrapper is a prefix." - wrapMode: Text.Wrap - color: Theme.textDim - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeXs - } + FormHint { text: "Direct launch only. Leading KEY=VALUE tokens set environment variables; %command% marks where the game goes, otherwise the wrapper is a prefix." } RowLayout { Layout.fillWidth: true spacing: 8 - Text { - Layout.preferredWidth: 150 - text: "After launch" - color: Theme.textMuted - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSizeMd - } + FormLabel { text: "After launch" } PxComboBox { id: afterLaunch Layout.preferredWidth: 220 @@ -299,6 +276,28 @@ Rectangle { Group { title: "Appearance" + RowLayout { + Layout.fillWidth: true + spacing: 8 + FormLabel { text: "Interface scale" } + PxComboBox { + id: uiScale + objectName: "interfaceScale" + Layout.preferredWidth: 120 + Accessible.name: "Interface scale" + textRole: "text" + valueRole: "value" + model: { + const scales = [100, 125, 150, 175, 200, 225, 250] + if (scales.indexOf(root.initial.uiScale) < 0) + scales.push(root.initial.uiScale) + scales.sort((a, b) => a - b) + return scales.map(value => ({ text: value + "%", value: value })) + } + Component.onCompleted: currentIndex = indexOfValue(root.initial.uiScale) + } + } + FormHint { text: settings.scaleOverrideNote || "Takes effect after restart." } PxCheckBox { id: compact text: "Compact mod list" 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 947bff04..42bacbd8 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 82% rename from src/pxmodrim/ui/plugins/steam_workshop/SteamWorkshop.qml rename to src/ui/SteamWorkshop.qml index 68f5f331..f055535e 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 @@ -25,6 +29,12 @@ Rectangle { } } + component NavButton: PxButton { + variant: "ghost" + implicitWidth: 28 + implicitHeight: 28 + } + ColumnLayout { anchors.fill: parent spacing: 0 @@ -40,44 +50,28 @@ Rectangle { anchors.rightMargin: 4 spacing: 4 - PxButton { - id: homeBtn - variant: "ghost" + NavButton { iconName: "home" - implicitWidth: 28 - implicitHeight: 28 ToolTip.text: "Home" onClicked: steamWorkshopPanel.navigateHome() } - PxButton { - id: backBtn - variant: "ghost" + NavButton { iconName: "chevron-left" - implicitWidth: 28 - implicitHeight: 28 ToolTip.text: "Back" enabled: webView.canGoBack onClicked: webView.goBack() } - PxButton { - id: fwdBtn - variant: "ghost" + NavButton { iconName: "chevron" - implicitWidth: 28 - implicitHeight: 28 ToolTip.text: "Forward" enabled: webView.canGoForward onClicked: webView.goForward() } - PxButton { - id: clearCacheBtn - variant: "ghost" + NavButton { iconName: "trash" - implicitWidth: 28 - implicitHeight: 28 ToolTip.text: "Clear cache" onClicked: { // Stale Steam SSR JS in the disk cache fails SRI and gets @@ -87,18 +81,13 @@ Rectangle { } } - PxButton { - id: reloadBtn - variant: "ghost" + NavButton { iconName: "refresh" - implicitWidth: 28 - implicitHeight: 28 ToolTip.text: "Reload" onClicked: webView.reload() } PxTextField { - id: urlInput Layout.fillWidth: true implicitHeight: 24 font.pixelSize: Theme.fontSizeSm @@ -131,9 +120,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 +154,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 9e820d99..a6e149ec 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 c5765494..8715f79c 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 97% rename from src/pxmodrim/ui/panels/TimeAnalytics.qml rename to src/ui/TimeAnalytics.qml index bff43edc..c189adc4 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 @@ -19,11 +19,9 @@ Rectangle { property string sortBy: "time" property bool includeBase: true - // JS objects are not observable; bump the revision to re-evaluate bindings. property var expandedMods: ({}) property var expandedPhases: ({}) property var showAllPhases: ({}) - property int expandRev: 0 readonly property real slowThreshold: 3.0 readonly property int phasePreview: 6 @@ -53,15 +51,15 @@ Rectangle { expandedMods = key ? { [key]: true } : {} expandedPhases = {} showAllPhases = {} - expandRev++ filterText = "" activeOnly = sourceData.active_count > 0 && (!selectedRow || selectedRow.active) Qt.callLater(scrollToMod, key) } - function toggle(map, key) { - map[key] = !map[key] - expandRev++ + function toggle(name, key) { + const next = Object.assign({}, root[name]) + next[key] = !next[key] + root[name] = next } function scrollToMod(key) { @@ -80,8 +78,7 @@ Rectangle { slowOnly = false if (!row.active) activeOnly = false - expandedMods[key] = true - expandRev++ + expandedMods = Object.assign({}, expandedMods, { [key]: true }) view = "mods" Qt.callLater(scrollToMod, key) } @@ -364,12 +361,12 @@ Rectangle { clip: true boundsBehavior: Flickable.StopAtBounds model: root.visibleMods - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: PxScrollBar {} delegate: Item { id: modRow required property var modelData - readonly property bool expanded: root.expandRev >= 0 && root.expandedMods[modelData.key] === true + readonly property bool expanded: root.expandedMods[modelData.key] === true width: ListView.view.width height: rowContent.implicitHeight @@ -454,7 +451,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggle(root.expandedMods, modRow.modelData.key) + onClicked: root.toggle("expandedMods", modRow.modelData.key) } } @@ -563,7 +560,7 @@ Rectangle { model: root.phases topMargin: 8 bottomMargin: 8 - ScrollBar.vertical: PxScrollBar { policy: ScrollBar.AsNeeded } + ScrollBar.vertical: PxScrollBar {} header: RowLayout { width: ListView.view.width @@ -577,8 +574,8 @@ Rectangle { delegate: Item { id: phaseRow required property var modelData - readonly property bool expanded: root.expandRev >= 0 && root.expandedPhases[modelData.label] === true - readonly property bool showAll: root.expandRev >= 0 && root.showAllPhases[modelData.label] === true + readonly property bool expanded: root.expandedPhases[modelData.label] === true + readonly property bool showAll: root.showAllPhases[modelData.label] === true readonly property var shownMods: showAll ? modelData.mods : modelData.mods.slice(0, root.phasePreview) width: ListView.view.width @@ -653,7 +650,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggle(root.expandedPhases, phaseRow.modelData.label) + onClicked: root.toggle("expandedPhases", phaseRow.modelData.label) } PxToolTip { visible: phaseMouse.containsMouse && phaseRow.modelData.tooltip.length > 0 @@ -729,7 +726,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: root.toggle(root.showAllPhases, phaseRow.modelData.label) + onClicked: root.toggle("showAllPhases", phaseRow.modelData.label) } } } diff --git a/src/pxmodrim/ui/plugins/workshop/Workshop.qml b/src/ui/Workshop.qml similarity index 90% rename from src/pxmodrim/ui/plugins/workshop/Workshop.qml rename to src/ui/Workshop.qml index 8528bc88..80c0da0e 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 @@ -27,27 +27,33 @@ Rectangle { font.pixelSize: Theme.fontSizeLg font.bold: true } - component CatalogGrid: GridView { - property int columns: Math.max(1, Math.floor(width / 230)) + component ThumbsUp: Image { + width: 16; height: 16 + sourceSize.width: 16; sourceSize.height: 16 + source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) + } + component CardGrid: GridView { + required property int minCellWidth + property int columns: Math.max(1, Math.floor(width / minCellWidth)) + cellWidth: width / columns + clip: true + } + component InlineGrid: CardGrid { Layout.fillWidth: true Layout.preferredHeight: Math.ceil(count / columns) * cellHeight - cellWidth: width / columns - cellHeight: 312 interactive: false - clip: true + } + component CatalogGrid: InlineGrid { + minCellWidth: 230 + cellHeight: 312 delegate: CatalogCard { width: GridView.view.cellWidth - 12 height: GridView.view.cellHeight - 12 } } - component CollectionGrid: GridView { - property int columns: Math.max(1, Math.floor(width / 400)) - Layout.fillWidth: true - Layout.preferredHeight: Math.ceil(count / columns) * cellHeight - cellWidth: width / columns + component CollectionGrid: InlineGrid { + minCellWidth: 400 cellHeight: 162 - interactive: false - clip: true delegate: CollectionBanner { width: GridView.view.cellWidth - 12 height: GridView.view.cellHeight - 12 @@ -76,6 +82,15 @@ Rectangle { } Item { implicitHeight: 12 } } + component ListingGrid: CardGrid { + id: grid + property var listing + Layout.fillWidth: true + Layout.fillHeight: true + ScrollBar.vertical: PxScrollBar {} + header: ListingHeader { width: grid.width } + footer: ListingFooter { width: grid.width; listing: grid.listing } + } ColumnLayout { anchors.fill: parent @@ -272,61 +287,40 @@ Rectangle { } } } - GridView { - id: listingGrid + ListingGrid { objectName: "listingScroll" readonly property bool listingTab: workshopPanel.configured && workshopPanel.tab === "Mods" - property int columns: Math.max(1, Math.floor(width / 230)) visible: !workshopPanel.hasDetail && listingTab - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - cellWidth: width / columns + minCellWidth: 230 cellHeight: 312 model: listingTab ? catalogMods : null - ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: listingGrid.width } - footer: ListingFooter { width: listingGrid.width; listing: catalogMods } + listing: catalogMods delegate: CatalogCard { width: GridView.view.cellWidth - 12 height: GridView.view.cellHeight - 12 } } - GridView { - id: collectionsGrid + ListingGrid { objectName: "collectionsScroll" readonly property bool listingTab: workshopPanel.configured && (workshopPanel.tab === "Collections" || workshopPanel.tab === "Favourites") - property int columns: Math.max(1, Math.floor(width / 400)) visible: !workshopPanel.hasDetail && listingTab - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - cellWidth: width / columns + minCellWidth: 400 cellHeight: 162 model: listingTab ? catalogCollections : null - ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: collectionsGrid.width } - footer: ListingFooter { width: collectionsGrid.width; listing: catalogCollections } + listing: catalogCollections delegate: CollectionBanner { width: GridView.view.cellWidth - 12 height: GridView.view.cellHeight - 12 } } - GridView { - id: installedGrid + ListingGrid { objectName: "installedScroll" readonly property bool active: workshopPanel.configured && workshopPanel.tab === "Installed" - property int columns: Math.max(1, Math.floor(width / 340)) visible: !workshopPanel.hasDetail && active - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - cellWidth: width / columns + minCellWidth: 340 cellHeight: 62 model: active ? catalogMods : null - ScrollBar.vertical: PxScrollBar {} - header: ListingHeader { width: installedGrid.width } - footer: ListingFooter { width: installedGrid.width; listing: catalogMods } + listing: catalogMods delegate: ModRow { width: GridView.view.cellWidth - 8 height: GridView.view.cellHeight - 8 @@ -465,11 +459,7 @@ Rectangle { RowLayout { visible: root.detail.kind === "mod" spacing: 8 - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } + ThumbsUp {} Copy { Layout.fillWidth: false; text: root.detail.votes || "Unrated" } Copy { Layout.fillWidth: false; text: (root.detail.fileSize || "Unknown") } } @@ -482,18 +472,9 @@ Rectangle { RowLayout { visible: !!root.detail.votesUp spacing: 8 - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } + ThumbsUp {} Copy { Layout.fillWidth: false; text: root.detail.votesUp || ""; Accessible.name: text + " positive votes" } - Image { - width: 16; height: 16 - sourceSize.width: 16; sourceSize.height: 16 - rotation: 180 - source: "image://icons/thumbs-up?color=" + encodeURIComponent(Theme.textMuted) - } + ThumbsUp { rotation: 180 } Copy { Layout.fillWidth: false; text: root.detail.votesDown || ""; Accessible.name: text + " negative votes" } } } 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/dialog_chrome.cpp b/src/ui/components/dialog_chrome.cpp new file mode 100644 index 00000000..6d79e2a0 --- /dev/null +++ b/src/ui/components/dialog_chrome.cpp @@ -0,0 +1,178 @@ +#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; + +// 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 = px::modrim::icon(u"close", 12, paletteValue(u"DANGER")); + m_hoverIcon = + px::modrim::icon(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: + explicit TitleBar(QDialog* dialog) : 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); + + 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 resizable) : QObject(dialog), m_dialog(dialog) { + m_bar = new TitleBar(dialog); + 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) { + if (qobject_cast(dialog) || + dialog->property("chromed").toBool()) { + return; + } + const bool grip = + !(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, 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 00000000..b5946291 --- /dev/null +++ b/src/ui/components/dialog_chrome.hpp @@ -0,0 +1,13 @@ +#pragma once + +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); + +} // namespace px::modrim diff --git a/src/ui/components/dialogs.hpp b/src/ui/components/dialogs.hpp new file mode 100644 index 00000000..e424b5a1 --- /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 00000000..4f07ab38 --- /dev/null +++ b/src/ui/components/filter_sidebar.cpp @@ -0,0 +1,24 @@ +#include "ui/components/filter_sidebar.hpp" + +#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")); + 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 00000000..b3817943 --- /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 00000000..88e8fdc4 --- /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 00000000..812e873d --- /dev/null +++ b/src/ui/components/header_controller.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::modrim { + +// State and commands behind Header.qml, exposed to it as `headerController`. +// 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 00000000..1bf17e5f --- /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 00000000..ae3a7b91 --- /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_table.inc b/src/ui/components/icon_table.inc new file mode 100644 index 00000000..a6387cc5 --- /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 00000000..8be48813 --- /dev/null +++ b/src/ui/components/icons.cpp @@ -0,0 +1,79 @@ +#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 (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("(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 QIcon(pixmap); +} + +} // namespace px::modrim diff --git a/src/ui/components/icons.hpp b/src/ui/components/icons.hpp new file mode 100644 index 00000000..b5cb36d4 --- /dev/null +++ b/src/ui/components/icons.hpp @@ -0,0 +1,18 @@ +#pragma once + +#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. Null icon for an unknown name. +QIcon 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 00000000..9602551d --- /dev/null +++ b/src/ui/components/mod_activation.cpp @@ -0,0 +1,70 @@ +#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")); +} +PublishedIds publishedIds(const CoreContext& ctx) { + PublishedIds ids; + const auto& mods = ctx.allMods(); + for (const auto& mod : mods) + if (auto id = mod->publishedFileId()) ids.installed.insert(*id); + for (const auto& uuid : ctx.activeUuids()) + if (const auto mod = mods.value(uuid)) + if (auto id = mod->publishedFileId()) ids.active.insert(*id); + return ids; +} +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 00000000..e2208ae2 --- /dev/null +++ b/src/ui/components/mod_activation.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#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); +struct PublishedIds { + QSet installed; + QSet active; +}; +PublishedIds publishedIds(const CoreContext& ctx); +} // namespace px::modrim diff --git a/src/ui/components/mod_updates.cpp b/src/ui/components/mod_updates.cpp new file mode 100644 index 00000000..b12cc366 --- /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 00000000..2941a088 --- /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 00000000..28905fa5 --- /dev/null +++ b/src/ui/components/popup_parent.cpp @@ -0,0 +1,101 @@ +#include "ui/components/popup_parent.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace px::modrim { + +namespace { + +bool isPopupWindow(const QObject* object) { + const auto* window = qobject_cast(object); + return window != nullptr && window->transientParent() != nullptr && + (window->type() == Qt::Popup || window->type() == Qt::ToolTip) && + qstrcmp(window->metaObject()->className(), "QQuickPopupWindow") == 0; +} + +const QObject* windowPopup(const QQuickWindow* popupWindow) { + for (const QQuickItem* child : popupWindow->contentItem()->childItems()) { + const QObject* popup = child->parent(); + if (popup != nullptr && popup->property("parent").value()) { + return popup; + } + } + return nullptr; +} + +// Qt Quick hands the compositor the popup's anchor rectangle in its own scene +// units, relative to the QQuickWidget. The compositor reads it as a rectangle +// in the top-level surface's native pixels, so it is off by the widget's +// position and, under QT_SCALE_FACTOR, by the scale. Same geometry as +// QQuickPopupWindow::parentControlGeometry, corrected, and handed over through +// the QtWayland popup anchor property. +void anchorToSurface(QQuickWindow* popupWindow) { + const QObject* popup = windowPopup(popupWindow); + if (popup == nullptr) { + return; + } + const auto* parentItem = popup->property("parent").value(); + if (parentItem->window() == nullptr) { + return; + } + bool scaleValid = false; + double scale = qEnvironmentVariable("QT_SCALE_FACTOR").toDouble(&scaleValid); + if (!scaleValid || scale <= 0) { + scale = 1; + } + + const QWindow* surfaceParent = popupWindow->transientParent(); + while (surfaceParent->handle() == nullptr && + surfaceParent->transientParent() != nullptr) { + surfaceParent = surfaceParent->transientParent(); + } + + const qreal overlap = popup->property("overlap").toReal(); + const QRectF bounds = parentItem->boundingRect(); + const QPointF topLeft = + parentItem->mapToScene(bounds.topLeft()) + + QPointF(parentItem->window()->position() - surfaceParent->position()); + const qreal width = + std::max(std::abs(bounds.width() - overlap * 2), bounds.width() / 4); + const QRect anchor(qFloor((topLeft.x() + overlap) * scale), + qFloor(topLeft.y() * scale), qCeil(width * scale), + qCeil(bounds.height() * scale)); + // An empty anchor is a protocol error; without one QtWayland falls back to + // the window geometry, which is already in surface pixels. + if (anchor.isValid()) { + popupWindow->setProperty("_q_waylandPopupAnchorRect", anchor); + } +} + +} // namespace + +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); + } + } + } else if (event->type() == QEvent::PlatformSurface && + static_cast(event)->surfaceEventType() == + QPlatformSurfaceEvent::SurfaceCreated && + QGuiApplication::platformName().startsWith( + QLatin1String("wayland")) && + isPopupWindow(watched)) { + anchorToSurface(static_cast(watched)); + } + 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 00000000..ad13f9f8 --- /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 00000000..947d7a5d --- /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 00000000..7cb862ee --- /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/svg_provider.cpp b/src/ui/components/svg_provider.cpp new file mode 100644 index 00000000..12c52d84 --- /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 00000000..fe13e8ac --- /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 00000000..69378476 --- /dev/null +++ b/src/ui/components/toast.cpp @@ -0,0 +1,176 @@ +#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"}, +}; + +} // namespace + +Toast::Toast(const QString& message, ToastLevel level, QWidget* parent, + const QString& actionLabel, std::function action) + : 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( + icon(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); + + if (!actionLabel.isEmpty() && action) { + auto* button = new QPushButton(actionLabel, this); + button->setObjectName(QStringLiteral("toastAction")); + connect(button, &QPushButton::clicked, this, + [this, button, action = std::move(action)] { + button->setEnabled(false); + dismiss(); + action(); + }); + layout->addWidget(button); + } + + auto* close = new QPushButton(this); + close->setObjectName(QStringLiteral("toastClose")); + close->setIcon(icon(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->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")); + hide(); + + 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, const QString& actionLabel, + std::function action) { + auto* toast = new Toast(message, level, this, actionLabel, std::move(action)); + m_layout->addWidget(toast); + show(); + raise(); + connect(toast, &Toast::dismissed, this, [this, toast] { remove(toast); }); + + toast->showWithAnimation(); + resizeToParent(); + + if (durationMs > 0) { + QTimer::singleShot(durationMs, toast, &Toast::dismiss); + } +} + +void ToastManager::info(const QString& message, int durationMs, + const QString& actionLabel, + std::function action) { + showToast(message, ToastLevel::kInfo, durationMs, actionLabel, + std::move(action)); +} + +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()) { + const QSize size = sizeHint().boundedTo(parent->size()); + setGeometry(parent->width() - size.width(), + parent->height() - size.height(), size.width(), size.height()); + } +} + +void ToastManager::remove(Toast* toast) { + toast->hide(); + m_layout->removeWidget(toast); + toast->deleteLater(); + resizeToParent(); + if (m_layout->count() == 0) hide(); +} + +} // namespace px::modrim diff --git a/src/ui/components/toast.hpp b/src/ui/components/toast.hpp new file mode 100644 index 00000000..9ac7a7cc --- /dev/null +++ b/src/ui/components/toast.hpp @@ -0,0 +1,66 @@ +#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, + const QString& actionLabel = {}, std::function action = {}); + + 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, const QString& actionLabel = {}, + std::function action = {}); + + void info(const QString& message, int durationMs = 3000, + const QString& actionLabel = {}, + std::function action = {}) 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 00000000..1a7cc23d --- /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 00000000..3d3c7dc0 --- /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 00000000..15bc2dbb --- /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 00000000..94090611 --- /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 00000000..a5d61d92 --- /dev/null +++ b/src/ui/config.cpp @@ -0,0 +1,48 @@ +#include "ui/config.hpp" + +#include + +#include + +namespace px::modrim { + +namespace { + +const QString kUiPrefsFile = QStringLiteral("ui_prefs.json"); + +} // namespace + +int loadUiScale(const ConfigService& svc) { + const auto raw = svc.loadRaw(kUiPrefsFile); + const auto found = raw.find("ui_scale"); + return found == raw.end() ? 100 : parseUiScale(found->second); +} + +Result loadUiPrefs(const ConfigService& svc) { + auto raw = svc.load(kUiPrefsFile); + if (!raw) { + return std::unexpected(raw.error()); + } + // An unusable scale must not fail the typed decode, which would replace + // every other preference with its default. + if (const auto found = raw->find("ui_scale"); found != raw->end()) { + found->second = static_cast(parseUiScale(found->second)); + } + UiPrefs prefs{}; + const auto text = glz::write_json(*raw); + if (!text) { + return prefs; + } + if (auto error = glz::read(prefs, *text)) { + spdlog::warn("Failed to load {}: {}", kUiPrefsFile.toStdString(), + glz::format_error(error, *text)); + return UiPrefs{}; + } + return prefs; +} + +Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc) { + return svc.save(kUiPrefsFile, prefs); +} + +} // namespace px::modrim diff --git a/src/ui/config.hpp b/src/ui/config.hpp new file mode 100644 index 00000000..88769a70 --- /dev/null +++ b/src/ui/config.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "core/config.hpp" +#include "core/error.hpp" +#include "ui/ui_prefs.hpp" + +namespace px::modrim { + +// The saved scale, read at startup before Qt and logging exist, so it never +// migrates or logs; an unreadable file gives 100. +int loadUiScale(const ConfigService& svc); + +// Reads ui_prefs.json. An invalid ui_scale loads as 100 and a valid one is +// clamped, without discarding the other prefs. Fails only when the file cannot +// be migrated. +Result loadUiPrefs(const ConfigService& svc); + +// Writes ui_prefs.json. Other UI code calls this after changing prefs. +Result saveUiPrefs(const UiPrefs& prefs, const ConfigService& svc); + +} // namespace px::modrim diff --git a/src/ui/context.cpp b/src/ui/context.cpp new file mode 100644 index 00000000..408d1365 --- /dev/null +++ b/src/ui/context.cpp @@ -0,0 +1,81 @@ +#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, + ResolvedScale initialScale) + : m_core(core), + m_uiPrefs(std::move(prefs)), + m_initialScale(std::move(initialScale)) {} + +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 00000000..4d900af3 --- /dev/null +++ b/src/ui/context.hpp @@ -0,0 +1,182 @@ +#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, + const QString& actionLabel = {}, + std::function action = {}) = 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 = {}, + ResolvedScale initialScale = {}); + 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; } + const ResolvedScale& initialScale() const noexcept { return m_initialScale; } + + // ── 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; + const ResolvedScale m_initialScale; + 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 00000000..fe1fa8e6 --- /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 ListedModPtr& mod = found.value(); + m_sink.showMod(mod, m_ctx.diagnosticsService().issuesFor(uuid)); + + std::optional packageId; + if (const auto* about = dynamic_cast(mod.get())) { + 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 00000000..08c77bc7 --- /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(ListedModPtr 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 00000000..5707581e --- /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 00000000..a0205540 --- /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 00000000..b388bfb3 --- /dev/null +++ b/src/ui/models/mod_list_model.cpp @@ -0,0 +1,289 @@ +#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); +} +static bool nameLess(const ModItem& a, const ModItem& b) { + return a.mod->name.toLower() < b.mod->name.toLower(); +} +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; +} +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 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(), nameLess); + 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; + } + } + m_activeCount = count; + 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 nameLess(m_items[a], m_items[b]); + }); + 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}); +} +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 00000000..cdfe59d8 --- /dev/null +++ b/src/ui/models/mod_list_model.hpp @@ -0,0 +1,86 @@ +#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 + 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; + const ModItem* getItem(int row) 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); + + 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 00000000..49c6db8e --- /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 00000000..8f8ce485 --- /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 00000000..efd8d3f9 --- /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 00000000..b0dfbeb5 --- /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 00000000..94eb9471 --- /dev/null +++ b/src/ui/navigation.cpp @@ -0,0 +1,38 @@ +#include "ui/navigation.hpp" + +#include +#include + +namespace px::modrim { + +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 00000000..56e8c670 --- /dev/null +++ b/src/ui/navigation.hpp @@ -0,0 +1,29 @@ +#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; + + 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 00000000..d5ebbbac --- /dev/null +++ b/src/ui/panels/about_panel.cpp @@ -0,0 +1,274 @@ +#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(QString(kIssuesUrl))); }); + 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 00000000..7d238f0d --- /dev/null +++ b/src/ui/panels/about_panel.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include + +#include + +class QPushButton; +namespace px::modrim { +class CoreContext; +} +namespace px::modrim { +class Notifier; +inline constexpr QLatin1StringView kIssuesUrl{ + "https://github.com/PyXiion/PxModRim/issues"}; +class AboutPanel : public QDialog { + public: + AboutPanel(QWidget* parent, CoreContext& ctx, Notifier& toast); + QString systemInformation() const; + + private: + static QStringList environment(); + static QString creditsHtml(); + 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 00000000..32a23acd --- /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 00000000..04eb5a2c --- /dev/null +++ b/src/ui/panels/keyboard_shortcuts_dialog.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include +#include +#include + +namespace px::modrim { +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 00000000..115ae317 --- /dev/null +++ b/src/ui/panels/mod_info_data.cpp @@ -0,0 +1,287 @@ +#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(const 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"; +} +ModInfo buildModInfo(CoreContext& ctx, const ListedMod& mod) { + const auto* about = dynamic_cast(&mod); + ModInfo data; + QVariantList needs, neededBy, details; + 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}, + {"uuid", found ? found->uuid() : QString{}}}); + } + QList incompatible; + for (const auto& pid : about->aboutRules.incompatibleWith) + if (const auto* other = index.value(pid.str(), nullptr)) + incompatible.append(other); + std::sort(incompatible.begin(), incompatible.end(), [](auto* a, auto* b) { + return a->name.toLower() < b->name.toLower(); + }); + for (const auto* other : incompatible) + data.conflictingMods.append( + QVariantMap{{"name", other->name}, {"uuid", other->uuid()}}); + 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"; + data.description = descriptionToHtml(mod.description); + data.info = { + {"name", mod.name}, + {"author", authors}, + {"provider", provider}, + {"status", QVariantMap{{"obsolete", mod.obsolete}, {"valid", mod.valid}}}, + {"version", + QVariantMap{{"known", known}, + {"ok", known && mod.supportedVersions.contains(target)}, + {"target", target}}}, + {"needs", needs}, + {"conflicts", QStringList{}}, + {"neededBy", neededBy}, + {"details", details}, + {"canOpenFolder", mod.modPath() && QFileInfo::exists(*mod.modPath())}, + {"canUpdate", source != nullptr}, + {"updateSource", source ? source->label() : QString{}}, + {"url", about ? about->url : QString{}}}; + return data; +} + +void updateActiveState(const QStringList& activeUuids, const QString& uuid, + ModInfo& data) { + const auto position = activeUuids.indexOf(uuid); + auto status = data.info.value("status").toMap(); + status.insert("active", position >= 0); + status.insert("position", position >= 0 ? position + 1 : 0); + status.insert("total", activeUuids.size()); + data.info.insert("status", status); + auto needs = data.info.value("needs").toList(); + for (auto& entry : needs) { + auto need = entry.toMap(); + const auto target = need.value("uuid").toString(); + need.insert("state", target.isEmpty() ? "missing" + : activeUuids.contains(target) ? "active" + : "inactive"); + entry = need; + } + data.info.insert("needs", needs); + QStringList conflicts; + for (const auto& entry : data.conflictingMods) { + const auto candidate = entry.toMap(); + if (activeUuids.contains(candidate.value("uuid").toString())) + conflicts.append(candidate.value("name").toString()); + } + data.info.insert("conflicts", conflicts); +} + +void updateIssues(const QList& issues, QVariantMap& info) { + QVariantList issueRows; + 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 auto count = errors ? errors : warnings; + auto status = info.value("status").toMap(); + status.insert("level", errors ? "error" : warnings ? "warning" : "ok"); + status.insert("title", count ? QString::number(count) + + (errors ? " error" : " warning") + + (count == 1 ? "" : "s") + : QStringLiteral("No issues")); + status.insert("issues", issueRows); + info.insert("status", status); +} +} // 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 00000000..5c4f2370 --- /dev/null +++ b/src/ui/panels/mod_info_data.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "core/context.hpp" +#include "core/models/view/diagnostics.hpp" +namespace px::modrim { +inline const QString kImgWidthToken = QStringLiteral("__IMG_WIDTH__"); +QString bbcodeImages(const 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); + +// The QML `info` map of one mod, split by what invalidates it: the mod's +// metadata and the game configuration, the active list, and the diagnostics. +struct ModInfo { + // Rendered description, exposed as its own QML property. + QString description; + // Incompatible mods present in the load order as {name, uuid}, sorted by + // name, so active-state updates only need the active set. + QVariantList conflictingMods; + // Name, author, provider, version, details, url, dependency topology + // (`needs` and `neededBy` with resolved UUIDs), and status obsolete/valid. + QVariantMap info; +}; + +// Everything only the mod's metadata and the game configuration determine. +ModInfo buildModInfo(CoreContext& ctx, const ListedMod& mod); + +// Fills status active/position/total, every `needs` entry's state and the +// active conflicting mod names for `activeUuids`. +void updateActiveState(const QStringList& activeUuids, const QString& uuid, + ModInfo& data); + +// Fills status level/title/issues. +void updateIssues(const QList& issues, QVariantMap& info); +} // 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 00000000..aa0e2a6f --- /dev/null +++ b/src/ui/panels/mod_info_panel.cpp @@ -0,0 +1,384 @@ +#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 + +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "core/services/diagnostics_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/startup_impact_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(); + }); + m_activeConnection = + m_ctx.activeStateChanged().connect([this](const QStringList&) { + if (!m_mod) return; + updateActiveState(m_ctx.activeUuids(), m_mod->uuid(), m_info); + setQml("info", m_info.info); + }); + m_finishedConnection = + m_ctx.downloads().downloadFinished.connect([this](const DownloadResult&) { + if (m_mod) + rebuildInfo(m_ctx.diagnosticsService().issuesFor(m_mod->uuid())); + }); + m_modsConnection = m_ctx.modService().modsChanged.connect( + [this](const std::monostate&) { refreshSelectedMod(); }); + m_configConnection = m_ctx.configChanged().connect([this](const AppConfig&) { + if (m_mod) rebuildInfo(m_ctx.diagnosticsService().issuesFor(m_mod->uuid())); + }); +} +ModInfoPanel::~ModInfoPanel() { + m_ctx.downloads().busyChanged.disconnect(m_busyConnection); + m_ctx.downloads().downloadFinished.disconnect(m_finishedConnection); + m_ctx.activeStateChanged().disconnect(m_activeConnection); + m_ctx.modService().modsChanged.disconnect(m_modsConnection); + m_ctx.configChanged().disconnect(m_configConnection); +} +void ModInfoPanel::setQml(const char* name, const QVariant& value) { + if (auto* root = m_qml->rootObject()) root->setProperty(name, value); +} +void ModInfoPanel::showMod(ListedModPtr mod, + const QList& issues) { + const auto token = ++m_previewToken; + m_mod = std::move(mod); + m_placeholder->hide(); + m_banner->show(); + m_qml->show(); + ++m_startupToken; + setQml("startup", QVariant{}); + rebuildInfo(issues); + syncUpdating(); + if (auto* root = m_qml->rootObject()) + QMetaObject::invokeMethod(root, "resetScroll"); + loadPreview(m_mod->modPath(), m_mod->name, token); +} +void ModInfoPanel::rebuildInfo(const QList& issues) { + m_info = buildModInfo(m_ctx, *m_mod); + updateActiveState(m_ctx.activeUuids(), m_mod->uuid(), m_info); + updateIssues(issues, m_info.info); + const auto* about = dynamic_cast(m_mod.get()); + m_banner->setTitle(m_mod->name, !m_mod->description.isEmpty() + ? firstSentence(m_mod->description) + : about ? about->packageId.str() + : QString{}); + setQml("info", m_info.info); + if (m_info.description != m_description) { + m_description = m_info.description; + setQml("description", m_description); + } +} +void ModInfoPanel::refreshSelectedMod() { + if (!m_mod) return; + const QString uuid = m_mod->uuid(); + const auto found = m_ctx.allMods().constFind(uuid); + if (found == m_ctx.allMods().cend()) { + clear(); + return; + } + m_mod = found.value(); + rebuildInfo(m_ctx.diagnosticsService().issuesFor(uuid)); + syncUpdating(); +} +void ModInfoPanel::setIssues(const QList& issues) { + if (!m_mod) return; + updateIssues(issues, m_info.info); + setQml("info", m_info.info); +} +void ModInfoPanel::clear() { + m_mod.reset(); + m_info = ModInfo{}; + ++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 (const auto saved = saveUiPrefs(m_prefs, m_ctx.configService()); !saved) + spdlog::warn("cannot save UI prefs: {}", + saved.error().message.toStdString()); +} +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); + const double own = result->selectedAvg; + if (!mod || own < kMinSeconds) { + 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; + const auto metrics = sortedMetrics(mod->metrics); + QVariantList rows; + for (qsizetype i = 0; i < std::min(3, metrics.size()); ++i) + if (metrics[i].second >= kMinSeconds) + 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 00000000..75e60e92 --- /dev/null +++ b/src/ui/panels/mod_info_panel.hpp @@ -0,0 +1,68 @@ +#pragma once +#include +#include +#include +#include + +#include "core/downloads/types.hpp" +#include "ui/mod_selection.hpp" +#include "ui/panels/mod_info_data.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(ListedModPtr 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(); + void rebuildInfo(const QList& issues); + void refreshSelectedMod(); + 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; + ModInfo m_info; + 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; + Event::Id m_finishedConnection; + Event::Id m_activeConnection; + Event::Id m_modsConnection; + Event::Id m_configConnection; +}; +} // 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 00000000..ea508927 --- /dev/null +++ b/src/ui/panels/mod_list_panel.cpp @@ -0,0 +1,212 @@ +#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); + searchInput->addAction(icon(u"search", 16, paletteValue(u"TEXT_DIM")), + QLineEdit::LeadingPosition); + searchLayout->addWidget(searchInput); + layout->addWidget(search); + 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; +} +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::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 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::moveRow(int source, int target) { + m_proxy->moveRow(source, target, true); +} +void ModListPanel::dragEnded() { m_ctx.setActive(m_model->activeUuids()); } +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 00000000..5d97e78e --- /dev/null +++ b/src/ui/panels/mod_list_panel.hpp @@ -0,0 +1,71 @@ +#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; + QStringList activeUuids() const; + ModListModel* model() const { return m_model; } + 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; + void setCompactMode(bool enabled); + Q_INVOKABLE bool isHighlighted(const QString& uuid) const; + Q_INVOKABLE void modSelected(const QString& uuid); + 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 void moveRow(int source, int target); + Q_INVOKABLE void dragEnded(); + signals: + void modPicked(const QString& uuid); + 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 00000000..79b6d67a --- /dev/null +++ b/src/ui/panels/restore_snapshot_dialog.cpp @@ -0,0 +1,215 @@ +#include "ui/panels/restore_snapshot_dialog.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/mods_config.hpp" +#include "core/utils.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; +} + +// 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 (!isAsciiDigits(date) || !isAsciiDigits(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 00000000..ca5713f0 --- /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 00000000..3d170433 --- /dev/null +++ b/src/ui/panels/settings_panel.cpp @@ -0,0 +1,235 @@ +#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); + } +}; +QString describeScaleOverride(const ResolvedScale& scale) { + if (scale.source == ScaleSource::kSaved) return {}; + if (scale.source == ScaleSource::kQtEnv) { + return QStringLiteral( + "Overridden by the QT_SCALE_FACTOR environment variable " + "(%1); this setting has no effect while it is set.") + .arg(scale.qtScaleFactor); + } + const QString origin = scale.source == ScaleSource::kCli + ? QStringLiteral("--ui-scale") + : QStringLiteral("PX_UI_SCALE"); + return QStringLiteral( + "This session uses %1% from %2; the saved value applies " + "when that is removed.") + .arg(scale.scale) + .arg(origin); +} +} // namespace +SettingsBackend::SettingsBackend(CoreContext& ctx, QDialog* dialog, + const std::vector& sections, + int uiScale, const ResolvedScale& initialScale) + : QObject(dialog), + m_ctx(ctx), + m_dialog(dialog), + m_scaleOverrideNote(describeScaleOverride(initialScale)) { + 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}, + {"uiScale", uiScale}, + {"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(); + 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); + 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, int uiScale, + const ResolvedScale& initialScale) + : QDialog(parent), + m_config(ctx.config()), + m_uiScale(uiScale), + 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, m_uiScale, initialScale); + 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(), + app.uiPrefs().ui_scale, app.initialScale()) {} +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); + m_uiScale = clampUiScale(values.value("uiScale", 100).toInt()); + 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 00000000..f6da0c39 --- /dev/null +++ b/src/ui/panels/settings_panel.hpp @@ -0,0 +1,83 @@ +#pragma once +#include +#include +#include + +#include + +#include "core/context.hpp" +#include "ui/settings_section.hpp" +#include "ui/ui_prefs.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) + Q_PROPERTY(QString scaleOverrideNote READ scaleOverrideNote CONSTANT) + public: + SettingsBackend(CoreContext& ctx, QDialog* dialog, + const std::vector& sections, int uiScale, + const ResolvedScale& initialScale); + 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; } + QString scaleOverrideNote() const { return m_scaleOverrideNote; } + 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; + QString m_scaleOverrideNote; + 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 = {}, + int uiScale = 100, const ResolvedScale& initialScale = {}); + SettingsPanel(AppContext& app, QQmlEngine* engine, QWidget* parent); + ~SettingsPanel() override; + const AppConfig& getConfig() const { return m_config; } + int getUiScale() const { return m_uiScale; } + + private: + void save(const QVariantMap& values); + AppConfig m_config; + int m_uiScale; + 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 00000000..87ad0195 --- /dev/null +++ b/src/ui/panels/sidebar_panel.cpp @@ -0,0 +1,48 @@ +#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_model->rowCount(); + m_model->updateEntries(entries); + if (reset) + if (const auto current = currentEntry()) emit entrySelected(*current); +} +std::optional SidebarPanel::currentEntry() const { + return m_model->getEntry(currentIndex()); +} +void SidebarPanel::selectAllEntry() { + auto* root = rootObject(); + auto* view = + root ? root->findChild(QStringLiteral("listView")) : nullptr; + if (!view) return; + for (int row = 0; row < m_model->rowCount(); ++row) + if (m_model->getEntry(row)->kind == view::SidebarEntry::Kind::kAll) { + view->setProperty("currentIndex", row); + onActivated(row); + return; + } +} +void SidebarPanel::onActivated(int row) { + if (const auto entry = m_model->getEntry(row)) emit entrySelected(*entry); +} +} // namespace px::modrim diff --git a/src/ui/panels/sidebar_panel.hpp b/src/ui/panels/sidebar_panel.hpp new file mode 100644 index 00000000..7dd444d3 --- /dev/null +++ b/src/ui/panels/sidebar_panel.hpp @@ -0,0 +1,27 @@ +#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; + std::optional currentEntry() const; + void selectAllEntry(); + signals: + void entrySelected(const px::modrim::view::SidebarEntry& entry); + + private: + void setEntries(const QList& entries); + void onActivated(int row); + CoreContext& m_ctx; + SidebarModel* m_model; + 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 00000000..a2024688 --- /dev/null +++ b/src/ui/panels/startup_impact_data.cpp @@ -0,0 +1,223 @@ +#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; +QList> sortedMetrics( + 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; }); + return sorted; +} +namespace { +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) { + const auto sorted = sortedMetrics(metrics); + 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 00000000..38302ee4 --- /dev/null +++ b/src/ui/panels/startup_impact_data.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "core/services/startup_impact_service/models.hpp" +#include "ui/models/impact.hpp" + +namespace px::modrim { +constexpr double kMinSeconds = 0.001; +// Metrics by descending value. +QList> sortedMetrics( + const QMap& metrics); +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 00000000..3a8e3278 --- /dev/null +++ b/src/ui/panels/time_analytics_panel.cpp @@ -0,0 +1,59 @@ +#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; + 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)); +} +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 00000000..786c0fbf --- /dev/null +++ b/src/ui/panels/time_analytics_panel.hpp @@ -0,0 +1,32 @@ +#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); + + 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 00000000..aa7078c9 --- /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 00000000..794f46a5 --- /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 00000000..f4110316 --- /dev/null +++ b/src/ui/panels/upload_report_dialog.cpp @@ -0,0 +1,181 @@ +#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 { +namespace { +enum class UploadChoice { kCancel = QDialog::Rejected, 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); +}; +} // namespace + +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 00000000..01d885f7 --- /dev/null +++ b/src/ui/panels/upload_report_dialog.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +#include + +namespace px::modrim { +class CoreContext; +class Notifier; +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 00000000..b4408038 --- /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() { + 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 00000000..e4648737 --- /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(); + 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 00000000..319c24b3 --- /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 00000000..af993b24 --- /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 00000000..a844acb1 --- /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&) { m_model->finish(); }); + 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 00000000..b3fb8a9c --- /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 00000000..e2676b26 --- /dev/null +++ b/src/ui/plugins/organizer/dialogs.cpp @@ -0,0 +1,150 @@ +#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); + + auto* ok = makeButton(verb, 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) + : 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(QStringLiteral("Move"), 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 00000000..49eb878d --- /dev/null +++ b/src/ui/plugins/organizer/dialogs.hpp @@ -0,0 +1,56 @@ +#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, labelled `verb`, +// is disabled while the name is blank. +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); + + // 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 00000000..5b3a8a1c --- /dev/null +++ b/src/ui/plugins/organizer/filter_model.cpp @@ -0,0 +1,79 @@ +#include "ui/plugins/organizer/filter_model.hpp" + +#include "ui/plugins/organizer/tree_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"); + return providerIcon(entry.providerId.value_or(QString{})); + 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 00000000..b6bd88a4 --- /dev/null +++ b/src/ui/plugins/organizer/filter_model.hpp @@ -0,0 +1,32 @@ +#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 + }; + 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 00000000..c121db3c --- /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 00000000..a10da9d5 --- /dev/null +++ b/src/ui/plugins/organizer/tree_model.cpp @@ -0,0 +1,375 @@ +#include "ui/plugins/organizer/tree_model.hpp" + +#include + +#include "ui/models/mod_list_model.hpp" +#include "ui/theme/palette.hpp" + +namespace px::modrim { +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"); +} + +namespace { +using namespace organizer; + +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(); + 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; +} +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()) {} + +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(organizer::FolderNode root, + const organizer::ModsByUuid& mods, + const QHash& providerColors, + bool filtering, + const QHash& tags) { + auto payload = std::make_unique(std::move(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::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 00000000..7b4ce4d5 --- /dev/null +++ b/src/ui/plugins/organizer/tree_model.hpp @@ -0,0 +1,102 @@ +#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"); + +QString providerIcon(const QString& id); + +struct TreeNode { + QString kind = QStringLiteral("root"); + 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; + 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 + }; + 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(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")); + + 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 00000000..74a2dcd9 --- /dev/null +++ b/src/ui/plugins/organizer/view.cpp @@ -0,0 +1,870 @@ +#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] { runSelectionAction(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); + auto tree = m_service.tree(query); + const auto ungrouped = + query.isEmpty() ? tree.mods.size() : m_service.tree().mods.size(); + m_model->setTree(std::move(tree), m_ctx.allMods(), + m_ctx.modService().providerColors(), !query.isEmpty(), + m_service.state().tags); + 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 saveNewTag(std::move(name), std::move(color)); +} +QCoro::QmlTask OrganizerViewPanel::updateTag(qint64 id, QString name, + QString color) { + return saveTag(id, std::move(name), std::move(color)); +} +QCoro::QmlTask OrganizerViewPanel::deleteTag(qint64 id) { + return removeTag(id); +} +QCoro::QmlTask OrganizerViewPanel::assignTag(qint64 id, bool add) { + return setTagAssigned(id, add); +} +QCoro::Task OrganizerViewPanel::saveNewTag(QString name, QString color) { + const QPointer alive(this); + 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(); + setEditorError(); +} +QCoro::Task OrganizerViewPanel::saveTag(qint64 id, QString name, + QString color) { + const QPointer alive(this); + const auto result = + co_await m_service.updateTag(id, std::move(name), std::move(color)); + if (!alive) co_return; + if (!result) { + setEditorError(result.error().message); + co_return; + } + Q_EMIT tagUpdated(id); + setEditorError(); +} +QCoro::Task OrganizerViewPanel::removeTag(qint64 id) { + const QPointer alive(this); + const auto result = co_await m_service.deleteTag(id); + if (!alive) co_return; + setEditorError(result ? QString{} : result.error().message); +} +QCoro::Task OrganizerViewPanel::setTagAssigned(qint64 id, bool add) { + const QPointer alive(this); + const auto result = co_await m_service.setModTags( + selectedPackageIds(), add ? QSet{id} : QSet{}, + add ? QSet{} : QSet{id}); + if (!alive) co_return; + setEditorError(result ? QString{} : result.error().message); +} +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::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_return; + if (action == QStringLiteral("move")) + co_await moveMods(pids); + else + co_await createFolderFrom(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::Task OrganizerViewPanel::newFolder() { + co_await createFolder(kRootId); +} +QCoro::Task> OrganizerViewPanel::askFolderName( + QString title, QString initial, QString verb) { + const QPointer alive(this); + const auto [code, dialog] = + co_await awaitDialog(title, initial, this, verb); + if (!alive || code != QDialog::Accepted) co_return std::nullopt; + co_return dialog->textValue(); +} +QCoro::Task> OrganizerViewPanel::askFolder( + QString title, QSet allowed) { + const QPointer alive(this); + const auto [code, dialog] = co_await awaitDialog( + m_service.tree(), title, allowed, this); + if (!alive || code != QDialog::Accepted) co_return std::nullopt; + co_return dialog->folderId(); +} +QCoro::Task OrganizerViewPanel::createFolder(qint64 parent) { + const QPointer alive(this); + const auto name = co_await askFolderName(QStringLiteral("New Folder"), {}, + QStringLiteral("Create")); + if (!name) co_return; + const auto created = co_await m_service.createFolder(*name, parent); + if (!alive) co_return; + if (!created) reportError(created.error()); +} +QCoro::Task OrganizerViewPanel::createFolderFrom(QStringList pids) { + const QPointer alive(this); + const auto name = + co_await askFolderName(QStringLiteral("Create Folder from Selection"), {}, + QStringLiteral("Create")); + if (!name) co_return; + const auto created = co_await m_service.createFolderFrom(*name, pids); + if (!alive) co_return; + if (!created) reportError(created.error()); +} +QCoro::Task OrganizerViewPanel::renameFolder(qint64 id) { + const QPointer alive(this); + const auto name = co_await askFolderName( + QStringLiteral("Rename Folder"), m_service.state().folders.value(id).name, + QStringLiteral("Rename")); + if (!name) co_return; + const auto result = co_await m_service.renameFolder(id, *name); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::moveMods(QStringList pids) { + QSet allowed; + for (auto it = m_service.state().folders.cbegin(); + it != m_service.state().folders.cend(); ++it) + allowed.insert(it.key()); + const auto destination = + co_await askFolder(QStringLiteral("Move to Folder"), std::move(allowed)); + if (destination) co_await placeMods(pids, *destination); +} +QCoro::Task OrganizerViewPanel::moveFolder(qint64 id) { + const QPointer alive(this); + const auto targets = m_service.folderMoveTargets(id); + if (!targets) { + reportError(targets.error()); + co_return; + } + const auto destination = + co_await askFolder(QStringLiteral("Move Folder"), *targets); + if (!destination) co_return; + const auto result = co_await m_service.moveFolder(id, *destination); + if (!alive) co_return; + if (!result) reportError(result.error()); +} +QCoro::Task OrganizerViewPanel::deleteFolder(qint64 id) { + const QPointer alive(this); + const auto [code, dialog] = co_await awaitDialog( + m_service.state().folders.value(id).name, this); + if (!alive || code != QMessageBox::Yes) co_return; + const auto 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] { moveMods(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] { createFolderFrom(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] { createFolder(id); }); + m_menu->addAction(QStringLiteral("Rename…"), this, + [this, id] { renameFolder(id); }); + const auto targets = m_service.folderMoveTargets(id); + m_menu + ->addAction(QStringLiteral("Move to…"), this, + [this, id] { moveFolder(id); }) + ->setEnabled(targets && !targets->isEmpty()); + m_menu->addAction(QStringLiteral("Delete…"), this, + [this, id] { deleteFolder(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 00000000..72ccf0cc --- /dev/null +++ b/src/ui/plugins/organizer/view.hpp @@ -0,0 +1,152 @@ +#pragma once + +#include +#include +#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); + void openTags(); + 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); + 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&); + QCoro::Task 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 saveNewTag(QString name, QString color); + QCoro::Task saveTag(qint64 id, QString name, QString color); + QCoro::Task removeTag(qint64 id); + QCoro::Task setTagAssigned(qint64 id, bool add); + 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> askFolderName(QString title, + QString initial, + QString verb); + QCoro::Task> askFolder(QString title, + QSet allowed); + QCoro::Task createFolder(qint64 parent); + QCoro::Task createFolderFrom(QStringList pids); + QCoro::Task renameFolder(qint64 id); + QCoro::Task moveMods(QStringList pids); + QCoro::Task moveFolder(qint64 id); + QCoro::Task deleteFolder(qint64 id); + 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 00000000..503fdaf8 --- /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 00000000..019d100f --- /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 00000000..7a562410 --- /dev/null +++ b/src/ui/plugins/steam_workshop/bridge_obj.cpp @@ -0,0 +1,128 @@ +#include "ui/plugins/steam_workshop/bridge_obj.hpp" + +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include + +#include "core/utils.hpp" +#include "ui/plugins/steam_workshop/plugin.hpp" +#include "ui/plugins/steam_workshop/view.hpp" + +namespace px::modrim { +namespace { +bool isPublishedFileId(const QJsonValue& value) { + return value.isString() && isAsciiDigits(value.toString()); +} +} // 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 00000000..5949e8ac --- /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 00000000..4b0e35ee --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_queue_model.cpp @@ -0,0 +1,64 @@ +#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 kDisplayRole: + return item.display; + case kStatusRole: + return item.status; + case kProgressRole: + return item.progress; + default: + return {}; + } +} + +QHash DownloadQueueModel::roleNames() const { + return {{kIdRole, "id"}, + {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) { + const bool titled = !title.isEmpty(); + m_items.append(Item{id, + titled ? QStringLiteral("%1 (%2)").arg(title, id) : 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 00000000..00058bf1 --- /dev/null +++ b/src/ui/plugins/steam_workshop/download_queue_model.hpp @@ -0,0 +1,42 @@ +#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, + 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, + qint64 total); + + private: + struct Item { + QString id; + 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 00000000..81b8083a --- /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 00000000..6111064d --- /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, + qint64 total); + 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 00000000..0f94d0c7 --- /dev/null +++ b/src/ui/plugins/steam_workshop/plugin.cpp @@ -0,0 +1,250 @@ +#include "ui/plugins/steam_workshop/plugin.hpp" + +#ifdef PX_HAS_STEAM + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/downloads/manager.hpp" +#include "core/mod_service.hpp" +#include "ui/components/mod_activation.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&) { refreshBadges(); }); + m_activeId = m_core->activeStateChanged().connect( + [this](const QStringList&) { refreshBadges(); }); + auto& downloads = m_core->downloads(); + m_statusId = downloads.downloadItemStatusChanged.connect( + [this](const DownloadItemStatus& status) { onItemStatus(status); }); + m_finishedId = downloads.downloadFinished.connect( + [this](const DownloadResult& result) { onDownloadFinished(result); }); + m_busyId = downloads.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); + auto& downloads = m_core->downloads(); + downloads.downloadItemStatusChanged.disconnect(m_statusId); + downloads.downloadFinished.disconnect(m_finishedId); + downloads.busyChanged.disconnect(m_busyId); + m_core = nullptr; + m_app = nullptr; +} + +void SteamWorkshopUiPlugin::toggleDownloadChecked(const QString& id, + const QString& title, + bool checked) { + batchToggleDownloadChecked({id}, {title}, checked); +} + +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() || !m_core) co_return; + if (m_core->config().paths.local.empty()) { + m_core->downloads().statusMessageChanged.notify( + QStringLiteral("Local mods path is not configured")); + co_return; + } + if (m_service->isDownloading()) { + m_core->downloads().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_core) + m_core->downloads().statusMessageChanged.notify(result.error().message); +} + +void SteamWorkshopUiPlugin::stopDownload() { m_service->cancel(); } +bool SteamWorkshopUiPlugin::isDownloading() const { + return m_service->isDownloading(); +} + +void SteamWorkshopUiPlugin::dropDownload(const QString& id) { + m_checkedIds.removeIf([&id](const auto& item) { return item.first == id; }); + m_statuses.remove(id); +} + +void SteamWorkshopUiPlugin::removeItem(const QString& id) { + dropDownload(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::refreshBadges() { + refreshCachedIds(); + badgesRefreshRequested.notify(m_installedIds.values()); + activeRefreshRequested.notify(m_activeIds.values()); +} + +void SteamWorkshopUiPlugin::syncAll() { + refreshBadges(); + 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) dropDownload(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; + auto ids = publishedIds(*m_core); + m_installedIds = std::move(ids.installed); + m_activeIds = std::move(ids.active); +} + +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 00000000..89a762c8 --- /dev/null +++ b/src/ui/plugins/steam_workshop/plugin.hpp @@ -0,0 +1,74 @@ +#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 refreshBadges(); + void dropDownload(const QString& id); + 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 7b04244f..b3144309 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 50919c15..287140fc 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 dce12eec..9db2f438 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 d0a0a97d..2c7a1ac4 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 37a30e48..c12c4257 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 b5e0766d..fd09f593 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 428dd3ae..324077c4 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 00000000..9f76dfcc --- /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 00000000..e44461aa --- /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() override; + void teardown() override; + 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 00000000..09eac7a2 --- /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 00000000..dbd5e513 --- /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 00000000..039f9814 --- /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 00000000..1390ba4e --- /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 00000000..84e6a626 --- /dev/null +++ b/src/ui/plugins/workshop/models.cpp @@ -0,0 +1,513 @@ +#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; + +enum Role { + kItemIdRole = Qt::UserRole + 1, + kKindRole, + kTitleRole, + kAuthorRole, + kPreviewUrlRole, + kCollageRole, + kSourceLabelRole, + kVersionsRole, + kTagsRole, + kStateRole, + kActiveRole, + kStateLabelRole, + kActionLabelRole, + kIncompatibleRole, + kMemberCountRole, + kDescriptionRole, + kWorkshopUrlRole, + kFileSizeRole, + kVotesRole, + kVotesUpRole, + kVotesDownRole, + kQueuedRole, + kFavouriteRole, + kSummaryRole, + kCompatLabelRole, +}; + +constexpr std::array, 25> kRoles{{ + {kItemIdRole, "itemId"}, + {kKindRole, "kind"}, + {kTitleRole, "title"}, + {kAuthorRole, "author"}, + {kPreviewUrlRole, "previewUrl"}, + {kCollageRole, "collage"}, + {kSourceLabelRole, "sourceLabel"}, + {kVersionsRole, "versions"}, + {kTagsRole, "tags"}, + {kStateRole, "state"}, + {kActiveRole, "active"}, + {kStateLabelRole, "stateLabel"}, + {kActionLabelRole, "actionLabel"}, + {kIncompatibleRole, "incompatible"}, + {kMemberCountRole, "memberCount"}, + {kDescriptionRole, "description"}, + {kWorkshopUrlRole, "workshopUrl"}, + {kFileSizeRole, "fileSize"}, + {kVotesRole, "votes"}, + {kVotesUpRole, "votesUp"}, + {kVotesDownRole, "votesDown"}, + {kQueuedRole, "queued"}, + {kFavouriteRole, "favourite"}, + {kSummaryRole, "summary"}, + {kCompatLabelRole, "compatLabel"}, +}}; + +QString kindName(CardKind kind) { + return kind == CardKind::kCollection ? QStringLiteral("collection") + : QStringLiteral("mod"); +} + +QString stateName(CardState state) { + switch (state) { + case CardState::kMissing: + return QStringLiteral("missing"); + case CardState::kInstalled: + return QStringLiteral("installed"); + case CardState::kOutdated: + return QStringLiteral("outdated"); + } + return {}; +} + +QString stateLabel(CardState state) { + switch (state) { + case CardState::kMissing: + return QStringLiteral("Not installed"); + case CardState::kInstalled: + return QStringLiteral("Installed"); + case CardState::kOutdated: + return QStringLiteral("Update available"); + } + return {}; +} + +QString actionLabel(CardAction action) { + switch (action) { + case CardAction::kDownload: + return QStringLiteral("Download"); + case CardAction::kInstalled: + return QStringLiteral("Installed"); + case CardAction::kUpdate: + return QStringLiteral("Update"); + case CardAction::kDownloadAll: + return QStringLiteral("Download all"); + case CardAction::kCompleteDownload: + return QStringLiteral("Complete download"); + case CardAction::kPreparing: + return QStringLiteral("Preparing…"); + case CardAction::kDownloading: + return QStringLiteral("Downloading"); + case CardAction::kQueued: + return QStringLiteral("Queued"); + } + return {}; +} + +QVariant roleValue(const CatalogCard& card, int role) { + switch (role) { + case kItemIdRole: + return card.itemId; + case kKindRole: + return kindName(card.kind); + case kTitleRole: + return card.title; + case kAuthorRole: + return card.author; + case kPreviewUrlRole: + return card.previewUrl; + case kCollageRole: + return card.collage; + case kSourceLabelRole: + return card.sourceLabel; + case kVersionsRole: + return card.versions; + case kTagsRole: + return card.tags; + case kStateRole: + return stateName(card.state); + case kActiveRole: + return card.active; + case kStateLabelRole: + return stateLabel(card.state); + case kActionLabelRole: + return actionLabel(card.action); + case kIncompatibleRole: + return card.incompatible; + case kMemberCountRole: + return card.memberCount; + case kDescriptionRole: + return QString{}; + case kWorkshopUrlRole: + return card.workshopUrl; + case kFileSizeRole: + return card.fileSize; + case kVotesRole: + return card.votes; + case kVotesUpRole: + return card.votesUp; + case kVotesDownRole: + return card.votesDown; + case kQueuedRole: + return card.queued; + case kFavouriteRole: + return card.favourite; + case kSummaryRole: + return card.summary; + case kCompatLabelRole: + return card.compatLabel; + } + return {}; +} + +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; +} + +QSet idsOf(const std::vector& ids) { + QSet result; + for (const auto& id : ids) result.insert(QString::fromStdString(id)); + return result; +} + +CardState cardState(InstallState state) { + switch (state) { + case InstallState::kMissing: + return CardState::kMissing; + case InstallState::kInstalled: + return CardState::kInstalled; + case InstallState::kOutdated: + return CardState::kOutdated; + } + return CardState::kMissing; +} + +QString descriptionHtml(const CatalogEntry& entry) { + return std::visit( + [](const auto& item) { + using Item = std::decay_t; + QString format = QStringLiteral("bbcode"); + if constexpr (std::is_same_v) + if (item.description_format == CollectionDescriptionFormat::kText) + format = QStringLiteral("text"); + return px::modrim::descriptionToHtml( + QString::fromStdString(item.description), format); + }, + entry); +} +} // 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( + reinterpret_cast(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); +} + +CatalogCard makeCard(const CatalogEntry& entry, const CardContext& context) { + return std::visit( + [&](const auto& item) { + using Item = std::decay_t; + constexpr bool kCollection = std::is_same_v; + CatalogCard card; + card.itemId = QString::fromStdString(item.id); + card.kind = kCollection ? CardKind::kCollection : CardKind::kMod; + card.title = QString::fromStdString(item.title); + 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 = !context.gameVersion.isEmpty() && + !versions.isEmpty() && + !versions.contains(context.gameVersion); + bool noCommon = false; + QString votes = QStringLiteral("Unrated"), up = "0", down = "0"; + QString workshopUrl, format = QStringLiteral("bbcode"); + if constexpr (kCollection) { + card.sourceLabel = item.source == CollectionSource::kPicked + ? "PxModRim pick" + : "Steam collection"; + card.action = CardAction::kDownloadAll; + card.memberCount = static_cast(item.member_count); + card.fileSize = 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()) card.collage.append(url); + } + } else { + card.state = cardState(context.installState(item)); + incompatible = incompatible || item.incompatible; + card.sourceLabel = QStringLiteral("Steam Workshop"); + switch (card.state) { + case CardState::kMissing: + card.action = CardAction::kDownload; + break; + case CardState::kInstalled: + card.action = CardAction::kInstalled; + break; + case CardState::kOutdated: + card.action = CardAction::kUpdate; + break; + } + card.fileSize = 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 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); + card.author = author; + card.previewUrl = safeUrl(preview); + card.versions = versionLabels.isEmpty() + ? QStringLiteral("Not specified") + : versionLabels.join(", "); + card.tags = tagLabels.join(QStringLiteral(" · ")); + card.workshopUrl = safeUrl(workshopUrl); + card.votes = votes; + card.votesUp = up; + card.votesDown = down; + card.summary = summarize(description, format); + card.compatLabel = + compatLabel(versions, context.gameVersion, incompatible, noCommon); + card.incompatible = incompatible; + + // Installation, activation and queue state, resolved exactly once from + // the refresh-wide snapshot. + if constexpr (kCollection) { + if (!context.detail) { + qsizetype installed = 0; + for (const auto& member : item.member_ids) + installed += + context.installedIds.contains(QString::fromStdString(member)); + if (!item.member_ids.empty() && + installed == static_cast(item.member_ids.size())) { + card.state = CardState::kInstalled; + card.action = CardAction::kInstalled; + } else if (installed) { + card.action = CardAction::kCompleteDownload; + } + } + card.favourite = + context.favouriteIds.contains(QString::fromStdString(item.id)); + } else { + card.active = context.activeIds.contains(card.itemId); + } + if constexpr (kCollection) { + if (context.preparingIds.contains(card.itemId)) { + card.queued = true; + card.action = CardAction::kPreparing; + } else { + QSet candidates = idsOf(item.member_ids); + candidates.unite(context.plannedMembers.value(card.itemId)); + if (context.packId == card.itemId) + candidates.unite(context.packIds); + if (candidates.intersects(context.downloadingIds)) { + card.queued = true; + card.action = CardAction::kDownloading; + } else if (candidates.intersects(context.queuedIds)) { + card.queued = true; + card.action = CardAction::kQueued; + } + } + } else if (card.state != CardState::kInstalled) { + if (context.preparingIds.contains(card.itemId)) { + card.queued = true; + card.action = CardAction::kPreparing; + } else if (context.downloadingIds.contains(card.itemId)) { + card.queued = true; + card.action = CardAction::kDownloading; + } else if (context.queuedIds.contains(card.itemId)) { + card.queued = true; + card.action = CardAction::kQueued; + } + } + return card; + }, + entry); +} + +QVariantMap cardDetail(const CatalogCard& card, const CatalogEntry& item) { + QVariantMap result; + for (const auto& [role, name] : kRoles) + result.insert(QString::fromLatin1(name), roleValue(card, role)); + result.insert(QStringLiteral("description"), descriptionHtml(item)); + return result; +} + +CatalogListModel::CatalogListModel(QObject* parent) + : QAbstractListModel(parent) {} + +void CatalogListModel::setPage(QList cards, int total, + std::optional cursor, bool append) { + if (append) { + QSet known; + for (const auto& card : m_rows) known.insert(card.itemId); + QList additions; + for (auto& card : cards) { + if (!known.contains(card.itemId)) { + known.insert(card.itemId); + additions.append(std::move(card)); + } + } + if (!additions.isEmpty()) { + beginInsertRows({}, count(), + count() + static_cast(additions.size()) - 1); + m_rows.append(std::move(additions)); + endInsertRows(); + } + } else if (cards.size() == m_rows.size() && + std::equal(cards.cbegin(), cards.cend(), m_rows.cbegin(), + [](const auto& a, const auto& b) { + return a.itemId == b.itemId; + })) { + for (qsizetype i = 0; i < cards.size(); ++i) { + if (cards.at(i) != m_rows.at(i)) { + m_rows[i] = std::move(cards[i]); + emit dataChanged(index(static_cast(i)), + index(static_cast(i))); + } + } + } else { + beginResetModel(); + m_rows = std::move(cards); + endResetModel(); + } + m_total = total; + m_cursor = std::move(cursor); + emit changed(); +} + +void CatalogListModel::refresh( + const std::function& convert) { + for (auto& card : m_rows) card = convert(card); + 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 { + if (!index.isValid() || index.row() < 0 || index.row() >= count()) return {}; + return roleValue(m_rows.at(index.row()), role); +} + +QHash CatalogListModel::roleNames() const { + QHash names; + for (const auto& [role, name] : kRoles) names.insert(role, name); + 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 00000000..f2e7e9d4 --- /dev/null +++ b/src/ui/plugins/workshop/models.hpp @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "core/workshop/types.hpp" + +namespace px::modrim::workshop { + +using workshop::CatalogEntry; + +enum class CardKind { kMod, kCollection }; +enum class CardState { kMissing, kInstalled, kOutdated }; +enum class CardAction { + kDownload, + kInstalled, + kUpdate, + kDownloadAll, + kCompleteDownload, + kPreparing, + kDownloading, + kQueued, +}; + +// Everything a card needs from outside its catalog entry, captured once per +// refresh so that each page renders against the same snapshot. +struct CardContext { + QString gameVersion; + std::function + installState; + QSet installedIds; + QSet activeIds; + QSet favouriteIds; + QSet preparingIds; + QSet downloadingIds; + QSet queuedIds; + QHash> plannedMembers; + QString packId; + QSet packIds; + bool detail = false; +}; + +struct CatalogCard { + QString itemId; + CardKind kind = CardKind::kMod; + QString title; + QString author; + QString previewUrl; + QStringList collage; + QString sourceLabel; + QString versions; + QString tags; + CardState state = CardState::kMissing; + bool active = false; + CardAction action = CardAction::kDownload; + bool incompatible = false; + int memberCount = 0; + QString workshopUrl; + QString fileSize; + QString votes; + QString votesUp; + QString votesDown; + bool queued = false; + bool favourite = false; + QString summary; + QString compatLabel; + + bool operator==(const CatalogCard&) const = default; +}; + +QString safeUrl(const QString& value); +QString summarize(const QString& text, + const QString& format = QStringLiteral("bbcode")); +CatalogCard makeCard(const CatalogEntry& item, const CardContext& context); +QVariantMap cardDetail(const CatalogCard& card, const CatalogEntry& item); + +class CatalogListModel : public QAbstractListModel { + Q_OBJECT + Q_PROPERTY(bool hasMore READ hasMore NOTIFY changed) + Q_PROPERTY(int count READ count NOTIFY changed) + + public: + explicit CatalogListModel(QObject* parent = nullptr); + + void setPage(QList cards, int total, + std::optional cursor, bool append = false); + void refresh(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; } + + 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 00000000..e3b875e3 --- /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 00000000..2c056e98 --- /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 00000000..41758c4c --- /dev/null +++ b/src/ui/plugins/workshop/view.cpp @@ -0,0 +1,642 @@ +#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.transform( + [](const std::string& value) { return QString::fromStdString(value); }); +} +std::optional nonEmpty(const QString& text) { + return text.isEmpty() ? std::nullopt : std::optional(text); +} +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) { refreshAndNotify(); }); + m_queueId = m_catalog.queueChanged.connect( + [this](const std::monostate&) { refreshAndNotify(); }); + 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 + refreshAndNotify(); + }); + m_activeId = m_ctx.activeStateChanged().connect([this](const QStringList&) { + refreshModIds(); + refreshAndNotify(); + }); + m_qml->setSource(QUrl(QStringLiteral("qrc:/qt/qml/PxModRim/Workshop.qml"))); + m_root->addWidget(m_qml, 1); +} + +WorkshopViewPanel::~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() { + auto ids = publishedIds(m_ctx); + m_installedIds = std::move(ids.installed); + m_activeIds = std::move(ids.active); +} +void WorkshopViewPanel::refreshAndNotify() { + refreshRows(); + emit changed(); +} + +void WorkshopViewPanel::remember(const CatalogEntry& item) { + m_items.insert(idOf(item), item); + std::visit( + [this](const auto& value) { + auto tags = idsOf(value.tags); + tags.subtract(idsOf(value.supported_versions)); + m_knownTags.unite(tags); + }, + item); + if (const auto* collection = std::get_if(&item)) + if (m_favourites.contains(collection->id)) + m_favourites.remember(*collection); +} + +CardContext WorkshopViewPanel::cardContext() { + refreshModIds(); + QSet favouriteIds; + for (const auto& item : m_favourites.items()) + favouriteIds.insert(QString::fromStdString(item.id)); + return CardContext{ + .gameVersion = gameVersion(), + .installState = + [this](const CatalogMod& mod) { return m_catalog.installState(mod); }, + .installedIds = m_installedIds, + .activeIds = m_activeIds, + .favouriteIds = std::move(favouriteIds), + .preparingIds = m_preparing, + .downloadingIds = m_downloads.activeIds(), + .queuedIds = m_catalog.queuedIds(), + .plannedMembers = m_collectionMods, + .packId = m_detailData ? idOf(m_detailData->item) : QString{}, + .packIds = m_packIds}; +} + +QVariantMap WorkshopViewPanel::detailRow(const Detail& detail, + const CardContext& context) { + auto card = makeCard(detail.item, context); + if (std::holds_alternative(detail.item)) { + if (card.previewUrl.isEmpty() && card.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()) card.collage.append(preview); + if (card.collage.size() == 4) break; + } + } + bool started = false; + for (const auto& member : detail.members) + if (auto* mod = std::get_if(&member)) + started |= m_catalog.installState(*mod) != InstallState::kMissing; + if (!card.queued) + card.action = + started ? CardAction::kCompleteDownload : CardAction::kDownloadAll; + if (auto copies = packCopies()) { + card.state = CardState::kInstalled; + card.active = std::ranges::all_of(*copies, [this](const auto& uuid) { + return m_ctx.activeUuids().contains(uuid); + }); + if (!card.queued) card.action = CardAction::kInstalled; + } + } + auto result = cardDetail(card, detail.item); + result.insert(QStringLiteral("complete"), detail.complete); + result.insert(QStringLiteral("warning"), + QString::fromStdString(detail.warning)); + return result; +} +void WorkshopViewPanel::refreshRows() { + auto context = cardContext(); + for (auto* model : {modsModel, collectionsModel, membersModel}) + model->refresh([this, &context](const CatalogCard& card) { + return makeCard(m_items.value(card.itemId), context); + }); + if (m_detailData) { + context.detail = true; + m_detail = detailRow(*m_detailData, context); + } +} +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->refreshAndNotify(); +} + +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) { + const auto context = cardContext(); + modsModel->setPage(cards(value.mods.items, context), + static_cast(value.mods.total), std::nullopt); + collectionsModel->setPage(cards(value.collections.items, context), + 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(cards(m_installed.nextPage(), cardContext()), + 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(cards(value.items, cardContext()), + 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(cards(value.items, cardContext()), + 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(cards(matches, cardContext()), + 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(cards(m_installed.visible(), cardContext()), + 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 = nonEmpty(tag), + .version = nonEmpty(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; + m_detailData = value; + remember(value.item); + auto context = cardContext(); + context.detail = true; + m_detail = detailRow(value, context); + context.detail = false; + membersModel->setPage(cards(value.members, context), + 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); + refreshAndNotify(); + 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->refreshAndNotify(); + 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(); + refreshAndNotify(); + if (m_tab == QStringLiteral("Installed") && !m_busy && !m_detailData) + co_await load(); +} +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 00000000..b004c307 --- /dev/null +++ b/src/ui/plugins/workshop/view.hpp @@ -0,0 +1,235 @@ +#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 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; + 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 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(); + void remember(const workshop::CatalogEntry& item); + CardContext cardContext(); + QVariantMap detailRow(const Detail& detail, const CardContext& context); + template + QList cards(const std::vector& items, + const CardContext& context) { + QList result; + result.reserve(static_cast(items.size())); + for (const auto& item : items) { + CatalogEntry entry{item}; + remember(entry); + result.append(makeCard(entry, context)); + } + return result; + } + void refreshRows(); + void refreshAndNotify(); + 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 00000000..3c80e4df --- /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 00000000..9a7d2830 --- /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 00000000..f68ffab1 --- /dev/null +++ b/src/ui/theme/constants.hpp @@ -0,0 +1,20 @@ +#pragma once + +namespace px::modrim { + +// 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 00000000..d931d6d1 --- /dev/null +++ b/src/ui/theme/palette.cpp @@ -0,0 +1,108 @@ +#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 (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(u"ELEVATE_2"); + return color; +} + +const QColor& textMainQ() { + static const QColor color = paletteColor(u"TEXT_MAIN"); + return color; +} + +const QColor& textMutedQ() { + static const QColor color = paletteColor(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(u"ELEVATE_0")); + palette.setColor(QPalette::WindowText, paletteColor(u"TEXT_MAIN")); + palette.setColor(QPalette::Base, paletteColor(u"ELEVATE_1")); + palette.setColor(QPalette::AlternateBase, paletteColor(u"ELEVATE_2")); + palette.setColor(QPalette::Text, paletteColor(u"TEXT_MAIN")); + palette.setColor(QPalette::Button, paletteColor(u"ELEVATE_2")); + palette.setColor(QPalette::ButtonText, paletteColor(u"TEXT_MAIN")); + palette.setColor(QPalette::BrightText, paletteColor(u"TEXT_MAIN")); + palette.setColor(QPalette::Highlight, paletteColor(u"PRIMARY")); + palette.setColor(QPalette::HighlightedText, paletteColor(u"ELEVATE_0")); + palette.setColor(QPalette::Link, paletteColor(u"PRIMARY")); + palette.setColor(QPalette::ToolTipBase, paletteColor(u"ELEVATE_2")); + palette.setColor(QPalette::ToolTipText, paletteColor(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 00000000..b9e10d48 --- /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 00000000..2d86fd2a --- /dev/null +++ b/src/ui/theme/palette_table.inc @@ -0,0 +1,27 @@ +// 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{"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 00000000..a37561f5 --- /dev/null +++ b/src/ui/theme/qml_theme.cpp @@ -0,0 +1,94 @@ +#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::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::railCollapseWidth() const { return kRailCollapseWidth; } + +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 00000000..213e5d90 --- /dev/null +++ b/src/ui/theme/qml_theme.hpp @@ -0,0 +1,114 @@ +#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 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 railCollapseWidth READ railCollapseWidth 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 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 railCollapseWidth() 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 80% rename from src/pxmodrim/ui/theme/style.qss rename to src/ui/theme/style.qss index 8071ea98..74e8bbc4 100644 --- a/src/pxmodrim/ui/theme/style.qss +++ b/src/ui/theme/style.qss @@ -29,7 +29,6 @@ QWidget#modInfoPanel { /* ── Transparent overrides ── */ QScrollArea, -QWidget#accordionContent, QWidget#toastManager { background-color: transparent; } @@ -110,7 +109,7 @@ QPushButton#primaryAction:hover { border-color: $PRIMARY_HOVER; } /* Platform themes (e.g. Breeze) add dark icons to standard dialog buttons that - vanish on our dark background; drop them and match AppButton sizing. */ + vanish on our dark background; drop them and match the appBtn sizing. */ QDialogButtonBox { dialogbuttonbox-buttons-have-icons: 0; } @@ -308,14 +307,6 @@ QLabel#organizerStatus { padding: 5px 12px; } -/* ── Info Panel ── */ -QWidget#infoArea { - background-color: $ELEVATE_2; -} -QWidget#depsSection { - background-color: transparent; -} - /* ── Placeholder ── */ QLabel#placeholder { color: $TEXT_DIM; @@ -323,64 +314,6 @@ QLabel#placeholder { padding: 32px; } -/* ── Description Browser ── */ -QTextBrowser#descriptionBrowser { - background: transparent; - border: none; - padding: 0; - font-size: 13px; - color: $TEXT_MUTED; - line-height: 1.5; -} - -/* ── Meta Chip ── */ -QWidget#metaChip { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 0; - min-width: 120px; -} -QLabel#metaChipLabel { - color: $TEXT_DIM; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; -} -QLabel#metaChipValue { - font-weight: 500; - font-size: 13px; - color: $TEXT_MAIN; -} - -/* ── Issues Label ── */ -QLabel#issuesLabel { - color: $TEXT_DIM; - font-style: italic; - font-size: 12px; -} - -/* ── Meta Labels ── */ -QLabel#metaLabel { - color: $TEXT_DIM; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; -} -QLabel#metaValue { - font-weight: 500; - font-size: 13px; - color: $TEXT_MAIN; -} -QWidget#metaWidget { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 8px 12px; -} - /* ── Status Bar ── */ QStatusBar { background-color: $ELEVATE_2; @@ -400,32 +333,6 @@ QToolTip { font-size: 11px; } -/* ── Accordion ── */ -QWidget#accordionHeader { - background: transparent; - border: none; - border-bottom: 1px solid $BORDER; -} -QWidget#accordionHeader:hover { - background: $ELEVATE_3; -} -QLabel#accordionTitle { - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; - color: $TEXT_DIM; -} -QLabel#accordionChevron { - font-size: 10px; - font-weight: 600; - color: $TEXT_DIM; - background: transparent; -} -QWidget#accordionContent { - background: transparent; -} - /* ── Group Box ── */ QGroupBox { border: 1px solid $BORDER; @@ -514,27 +421,6 @@ QPushButton#dialogCloseBtn:hover { border-color: $DANGER; } -/* ── Icon Buttons ── */ -QPushButton#iconBtn { - background-color: $ELEVATE_3; - border: 1px solid $BORDER; - border-radius: 6px; - padding: 0; - min-height: 0; -} -QPushButton#iconBtn:hover { - background-color: $ELEVATE_4; - border-color: $ELEVATE_4; -} -QPushButton#iconBtn[primary="true"] { - background-color: $PRIMARY; - border-color: $PRIMARY; -} -QPushButton#iconBtn[primary="true"]:hover { - background-color: $PRIMARY_HOVER; - border-color: $PRIMARY_HOVER; -} - /* ── Toast ── */ QWidget#toast { background-color: $SURFACE; @@ -587,18 +473,6 @@ QProgressBar::chunk { background: $PRIMARY; border-radius: 3px; } -QProgressBar#thinProgress { - border: none; - border-radius: 2px; - background: $ELEVATE_3; - height: 4px; - max-height: 4px; - min-height: 4px; - text-align: left; -} -QProgressBar#thinProgress::chunk { - border-radius: 2px; -} /* ── About Dialog ── */ QDialog#aboutPanel, 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 00000000..1232f33f --- /dev/null +++ b/src/ui/ui_prefs.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/constants.hpp" + +namespace px::modrim { + +constexpr int clampUiScale(int percent) { + return std::clamp(percent, 100, 250); +} + +inline int parseUiScale(const glz::json_t& value) { + if (!value.is_number()) return 100; + const double percent = value.get(); + if (!std::isfinite(percent) || std::trunc(percent) != percent) return 100; + return static_cast(std::clamp(percent, 100.0, 250.0)); +} + +inline QByteArray uiScaleFactor(int percent) { + return QByteArray::number(clampUiScale(percent) / 100.0, 'g', 3); +} + +enum class ScaleSource { kSaved, kCli, kEnv, kQtEnv }; + +struct ResolvedScale { + double scale = 100; + ScaleSource source = ScaleSource::kSaved; + // The user's QT_SCALE_FACTOR, set only when source is kQtEnv. + QString qtScaleFactor; +}; + +inline std::optional parseScaleOverride(const QString& value) { + QStringView digits = QStringView(value).trimmed(); + if (digits.isEmpty()) return std::nullopt; + const bool negative = digits.front() == u'-'; + if (negative || digits.front() == u'+') digits = digits.sliced(1); + if (digits.isEmpty()) return std::nullopt; + int percent = 0; + for (const QChar digit : digits) { + if (digit < u'0' || digit > u'9') return std::nullopt; + percent = std::min(250, percent * 10 + digit.unicode() - u'0'); + } + return negative ? 100 : clampUiScale(percent); +} + +// The last --ui-scale value before "--": null when absent, empty when given +// without a value. +inline QString uiScaleArgument(const QStringList& arguments) { + QString value; + for (qsizetype index = 1; index < arguments.size(); ++index) { + const auto& argument = arguments[index]; + if (argument == QStringLiteral("--")) break; + if (argument.startsWith(QStringLiteral("--ui-scale="))) { + value = argument.sliced(11); + } else if (argument == QStringLiteral("--ui-scale")) { + value = QStringLiteral(""); + if (index + 1 < arguments.size() && + !arguments[index + 1].startsWith(QStringLiteral("--"))) { + value = arguments[++index]; + } + } + } + return value; +} + +// A set QT_SCALE_FACTOR wins, then a valid CLI value, then a valid +// PX_UI_SCALE, then the saved value. Null strings mean "not set". +inline ResolvedScale resolveUiScale(int saved, const QString& cliValue = {}, + const QString& envValue = {}, + const QString& qtScaleFactor = {}) { + if (!qtScaleFactor.isNull()) { + bool ok = false; + const double percent = qtScaleFactor.toDouble(&ok) * 100; + const bool usable = ok && percent > 0 && std::isfinite(percent); + return {usable ? percent : 100, ScaleSource::kQtEnv, qtScaleFactor}; + } + if (const auto scale = parseScaleOverride(cliValue)) { + return {static_cast(*scale), ScaleSource::kCli, {}}; + } + if (const auto scale = parseScaleOverride(envValue)) { + return {static_cast(*scale), ScaleSource::kEnv, {}}; + } + return {static_cast(clampUiScale(saved)), ScaleSource::kSaved, {}}; +} + +// 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; + int ui_scale = 100; + + 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 00000000..360053e1 --- /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 00000000..365fd096 --- /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 00000000..9d6f6307 --- /dev/null +++ b/src/ui/views/mods_view.cpp @@ -0,0 +1,39 @@ +#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); + 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 00000000..0cfbc170 --- /dev/null +++ b/src/ui/views/mods_view.hpp @@ -0,0 +1,24 @@ +#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); +}; +} // namespace px::modrim diff --git a/src/ui/window/actions.cpp b/src/ui/window/actions.cpp new file mode 100644 index 00000000..46d6fca0 --- /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 00000000..46283135 --- /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/geometry.hpp b/src/ui/window/geometry.hpp new file mode 100644 index 00000000..fc1d914b --- /dev/null +++ b/src/ui/window/geometry.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace px::modrim { + +// Clamps the size to at least 900x560 and to the screen less a 20px margin +// per side, but never beyond the screen, then moves the window on screen +// keeping up to that margin. +inline QRect fitWindowGeometry(const QRect& desired, const QRect& available) { + static constexpr int kMargin = 20; + const auto fitLength = [](int requested, int extent, int floor) { + const int maximum = std::min(extent, std::max(floor, extent - 2 * kMargin)); + return std::clamp(requested, std::min(floor, maximum), maximum); + }; + const auto fitPosition = [](int requested, int start, int extent, + int length) { + const int margin = std::min(kMargin, (extent - length) / 2); + return std::clamp(requested, start + margin, + start + extent - length - margin); + }; + const int width = fitLength(desired.width(), available.width(), 900); + const int height = fitLength(desired.height(), available.height(), 560); + return {fitPosition(desired.x(), available.x(), available.width(), width), + fitPosition(desired.y(), available.y(), available.height(), height), + width, height}; +} + +} // namespace px::modrim diff --git a/src/ui/window/main_window.cpp b/src/ui/window/main_window.cpp new file mode 100644 index 00000000..698e0e27 --- /dev/null +++ b/src/ui/window/main_window.cpp @@ -0,0 +1,844 @@ +#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 + +#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/geometry.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); +} + +namespace { +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 LaunchConfirmDialog : public QMessageBox { + public: + LaunchConfirmDialog(QWidget* parent, const QString& title, + const QString& text, const QString& info, + const QString& accept); + bool dontAskAgain() const; +}; +} // namespace + +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"))); + 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); + auto* 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(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_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); }); + m_warningConnection = diagnostics.warningRaised.connect( + [this](const QString& message) { m_toasts->warning(message); }); + 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(); + const QRect available = screen()->availableGeometry(); + const QRect centred(available.center() - QPoint(700, 425), QSize(1400, 850)); + setGeometry(fitWindowGeometry(centred, available)); +} + +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); + diagnostics.warningRaised.disconnect(m_warningConnection); + 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(QString(kIssuesUrl))); }); + bind(ActionId::kOpenLogs, [] { + QDesktopServices::openUrl(QUrl::fromLocalFile( + QDir(configDir()).filePath(QStringLiteral("logs")))); + }); + bind(ActionId::kUploadLogs, + [this] { handleUploadReport(this, m_ctx, *m_toasts); }); + bind(ActionId::kShortcuts, [this] { + awaitDialog(shortcutRows() + kQmlShortcuts, this); + }); + bind(ActionId::kCheckUpdates, [this] { checkUpdates(true); }); + bind(ActionId::kAbout, + [this] { awaitDialog(this, m_ctx, *m_toasts); }); + 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::requestRestart() { + if (m_closing || m_closePromptOpen) return; + m_restartRequested = true; + close(); +} + +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() { + 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; + bool proceed = result == QMessageBox::Discard; + if (result == QMessageBox::Save) proceed = co_await saveActiveMods(); + if (!proceed) { + m_restartRequested = false; + 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 result = co_await m_ctx.autoSort(); + if (result.stale) { + m_toasts->warning( + QStringLiteral("Mods changed while sorting; the order was left as is")); + co_return; + } + m_toasts->success(QStringLiteral("Sorted %1 mods in %2ms") + .arg(result.count) + .arg(result.elapsedMs, 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; + } + // Other prefs may have changed while Settings was open (e.g. skipping an + // update), so only the scale comes from the dialog. + auto prefs = m_app.uiPrefs(); + prefs.ui_scale = dialog->getUiScale(); + const auto prefsSaved = saveUiPrefs(prefs, m_ctx.configService()); + if (!prefsSaved) { + m_toasts->error(prefsSaved.error().message); + co_return; + } + m_app.uiPrefs() = prefs; + m_ctx.updateConfig(config); + m_ctx.resetProviders(config.paths); + const auto& initialScale = m_app.initialScale(); + if (initialScale.source == ScaleSource::kSaved && + prefs.ui_scale != initialScale.scale) { + m_toasts->info( + QStringLiteral("Restart PxModRim to apply the interface scale."), 0, + QStringLiteral("Restart now"), [this] { requestRestart(); }); + } + 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::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 00000000..6ed00672 --- /dev/null +++ b/src/ui/window/main_window.hpp @@ -0,0 +1,123 @@ +#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 MainWindow : public QMainWindow { + Q_OBJECT + public: + explicit MainWindow(AppContext& app, QWidget* parent = nullptr); + ~MainWindow() override; + + void setAppQuitCallback(std::function callback); + bool restartRequested() const noexcept { return m_restartRequested; } + 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 requestRestart(); + 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 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; + 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_restartRequested = 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_warningConnection = 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 00000000..76df52dd --- /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 00000000..fabf9519 --- /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/src/ui/window/restart.hpp b/src/ui/window/restart.hpp new file mode 100644 index 00000000..1accd1d9 --- /dev/null +++ b/src/ui/window/restart.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace px::modrim { + +struct RestartCommand { + QString program; + QStringList arguments; + QProcessEnvironment environment; +}; + +// Compares whole path components, so /tmp/app does not contain /tmp/app2. +inline bool isWithinDirectory(const QString& path, const QString& directory) { + const auto pathParts = path.split(u'/', Qt::SkipEmptyParts); + const auto directoryParts = directory.split(u'/', Qt::SkipEmptyParts); + return pathParts.size() > directoryParts.size() && + std::equal(directoryParts.begin(), directoryParts.end(), + pathParts.begin()); +} + +// The program to relaunch: the AppImage when this executable runs from inside +// one, otherwise the executable itself. An AppImage runs its executable from a +// mount or extraction directory (APPDIR) that the runtime removes once this +// process exits. APPIMAGE alone is not proof, because processes started by +// another AppImage inherit it. `canonicalPath` returns an empty string for a +// missing path. +inline QString restartProgram( + const QProcessEnvironment& environment, const QString& executable, + const std::function& isFile, + const std::function& canonicalPath) { + const QString appImage = environment.value(QStringLiteral("APPIMAGE")); + const QString appDir = environment.value(QStringLiteral("APPDIR")); + if (appImage.isEmpty() || appDir.isEmpty() || !isFile(appImage)) { + return executable; + } + // A missing APPDIR canonicalises to "", which would contain every path. + const QString canonicalAppDir = canonicalPath(appDir); + const bool inside = + !canonicalAppDir.isEmpty() && + isWithinDirectory(canonicalPath(executable), canonicalAppDir); + return inside ? appImage : executable; +} + +// `keepQtScale` keeps a QT_SCALE_FACTOR the user set; otherwise the one this +// process exported is dropped so the saved scale applies again. +inline RestartCommand restartCommand(QString program, + const QStringList& originalArguments, + QProcessEnvironment environment, + bool keepQtScale) { + if (!keepQtScale) { + environment.remove(QStringLiteral("QT_SCALE_FACTOR")); + } + return {std::move(program), originalArguments.sliced(1), + std::move(environment)}; +} + +} // namespace px::modrim diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..ee0de1ba --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,13 @@ +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) + +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 e69de29b..00000000 diff --git a/tests/benchmarks/bench_discovery.py b/tests/benchmarks/bench_discovery.py deleted file mode 100644 index e4247633..00000000 --- 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 c6710442..00000000 --- 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 6fdc7b5a..00000000 --- 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 00000000..57930f4a --- /dev/null +++ b/tests/core/checker/test_checker.cpp @@ -0,0 +1,168 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/checker/checker.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].shortMessage == + QStringLiteral("Missing dependencies")); + + 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 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(), QStringLiteral("1.5")); + 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(), QStringLiteral("1.5")); + 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 00000000..3a0a08ef --- /dev/null +++ b/tests/core/checker/test_databases.cpp @@ -0,0 +1,164 @@ +#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("NoVersionWarningDb load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + NoVersionWarningDb service(config, network); + + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); +} + +TEST_CASE("NoVersionWarningDb 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; + NoVersionWarningDb 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("NoVersionWarningDb 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; + NoVersionWarningDb service(config, network); + + const auto pids = service.loadIfExists(); + CHECK(pids.size() == 1); + CHECK(pids.contains(PackageId(QStringLiteral("lone.mod")))); +} + +TEST_CASE("NoVersionWarningDb 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; + NoVersionWarningDb service(config, network); + + CHECK(service.loadIfExists().isEmpty()); +} + +TEST_CASE("UseThisInsteadDb load_if_exists on missing file returns empty") { + QTemporaryDir dir; + ConfigService config(dir.path()); + QNetworkAccessManager network; + UseThisInsteadDb service(config, network); + + CHECK(!service.cacheAgeS().has_value()); + CHECK(service.loadIfExists().isEmpty()); +} + +TEST_CASE("UseThisInsteadDb 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; + UseThisInsteadDb 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")); +} + +TEST_CASE("UseThisInsteadDb 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; + UseThisInsteadDb 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 00000000..df062408 --- /dev/null +++ b/tests/core/checker/test_graph.cpp @@ -0,0 +1,405 @@ +#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.nodes().contains(PackageId(QStringLiteral("mod.a")))); + CHECK(g.nodes().contains(PackageId(QStringLiteral("mod.b")))); + CHECK(g.nodes().size() == 2); + CHECK(g.pidToIndex().value(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.pidToIndex().value(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 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 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.pidToIndex().value(PackageId(QStringLiteral("mod.a"))) == 0); + CHECK(g.pidToIndex().value(PackageId(QStringLiteral("mod.b"))) == 1); + + g.updateOrder( + {PackageId(QStringLiteral("mod.b")), PackageId(QStringLiteral("mod.a"))}); + CHECK(g.pidToIndex().value(PackageId(QStringLiteral("mod.a"))) == 1); + CHECK(g.pidToIndex().value(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 00000000..b81c6215 --- /dev/null +++ b/tests/core/checker/test_issues.cpp @@ -0,0 +1,281 @@ +#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].shortMessage == QStringLiteral("Missing dependencies")); + 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].shortMessage == QStringLiteral("Incompatibilities detected")); + 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].shortMessage == QStringLiteral("Incompatibilities detected")); +} + +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].shortMessage == QStringLiteral("Load order violated")); + 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].shortMessage == QStringLiteral("Load order violated")); + 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].shortMessage == QStringLiteral("Dependency cycle detected")); + 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].shortMessage == 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 00000000..0892c156 --- /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; + manager().notifyBusy(true); + m_busy = false; + manager().notifyBusy(false); + DownloadResult result{.succeeded = modIds, .failed = {}, .changed = {}}; + manager().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); }); + + manager.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 00000000..5c102bc0 --- /dev/null +++ b/tests/core/downloads/test_steam.cpp @@ -0,0 +1,850 @@ +#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(); + } + + 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.manager.downloadItemStatusChanged.connect( + [&](const auto& s) { statuses.push_back(s); }); + svc.manager.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.manager.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.manager.busyChanged.connect(observe); + svc.manager.batchStarted.connect(observe); + svc.manager.statusMessageChanged.connect(observe); + svc.manager.downloadPhaseChanged.connect(observe); + svc.manager.downloadProgress.connect(observe); + svc.manager.downloadItemStatusChanged.connect(observe); + svc.manager.downloadItemTitled.connect(observe); + svc.manager.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.manager.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.manager.busyChanged.connect( + [&](bool busy) { events.push_back(busy ? "busy:1" : "busy:0"); }); + svc.manager.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.manager.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.manager.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.manager.downloadItemTitled.connect( + [&](const auto& t) { titles.insert(t.modId, t.title); }); + std::vector phases; + svc.manager.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 00000000..205c5cfa --- /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 00000000..3689990f --- /dev/null +++ b/tests/core/models/metadata/test_schemas.cpp @@ -0,0 +1,48 @@ +#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); +} diff --git a/tests/core/models/metadata/test_structures.cpp b/tests/core/models/metadata/test_structures.cpp new file mode 100644 index 00000000..094ab9f1 --- /dev/null +++ b/tests/core/models/metadata/test_structures.cpp @@ -0,0 +1,92 @@ +#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("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("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"))); +} diff --git a/tests/core/organizer/test_db.cpp b/tests/core/organizer/test_db.cpp new file mode 100644 index 00000000..52c4f9a2 --- /dev/null +++ b/tests/core/organizer/test_db.cpp @@ -0,0 +1,380 @@ +#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}, + }; + expect(wait(db.replaceRules(specs))); + + const OrganizerState st = expect(wait(db.load())); + CHECK(st.rules == specs); + + // 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] == specs[1]); + 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].pattern == QStringLiteral("a")); + CHECK(st.rules[0].folderId == existing.id); + CHECK(st.rules[1].pattern == QStringLiteral("b")); + CHECK(st.rules[1].folderId == existing.id); + 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 00000000..a12afbef --- /dev/null +++ b/tests/core/organizer/test_resolve.cpp @@ -0,0 +1,647 @@ +#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 (const auto& [fld, op, pat, fid] : rules) { + state.rules.append( + RuleSpec{.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 tags only produces presets and providers") { + const ModsByUuid mods{{QStringLiteral("u1"), + makeMod(QStringLiteral("u1"), QStringLiteral("One"), + QStringLiteral("a.one"))}}; + + const OrganizerState emptyState = makeState(); + const QList filters = treeFilters(mods, {}, emptyState); + QStringList keys; + for (const TreeFilter& f : filters) { + keys.append(f.key); + } + CHECK(keys == QStringList{QStringLiteral("all"), QStringLiteral("active"), + QStringLiteral("inactive"), + QStringLiteral("provider:local")}); +} + +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 00000000..0d5f1f7c --- /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 00000000..699b7eb6 --- /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 00000000..e928ac4d --- /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/folder.hpp" + +using px::modrim::PathConfig; +using px::modrim::providers::createProviders; +using px::modrim::providers::FolderModProvider; + +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 00000000..11069068 --- /dev/null +++ b/tests/core/providers/test_providers.cpp @@ -0,0 +1,254 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "core/providers/core.hpp" +#include "core/providers/folder.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); + + FolderModProvider provider(QStringLiteral("steam"), QStringLiteral("#3498db"), + workshop, true); + 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); + + FolderModProvider localProvider(QStringLiteral("local"), + QStringLiteral("#2ecc71"), local, false); + FolderModProvider downloadedProvider(QStringLiteral("downloaded"), + QStringLiteral("#3498db"), local, true); + 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; + FolderModProvider provider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + root.path(), false, &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)); + + FolderModProvider provider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + root.path(), false); + const auto discovered = runToCompletion( + provider.discover(QStringLiteral("1.5"), nullptr, &cache)); + 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; + FolderModProvider provider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + root.path(), false); + const auto discovered = runToCompletion( + provider.discover(QStringLiteral("1.5"), nullptr, &cache)); + 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(); + + FolderModProvider provider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + root.path(), false); + const auto discovered = runToCompletion(provider.discover( + QStringLiteral("1.5"), nullptr, &cache, /*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 00000000..c950655b --- /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 00000000..bc94e72e --- /dev/null +++ b/tests/core/services/startup_impact_service/test_db.cpp @@ -0,0 +1,307 @@ +#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. + const auto snap = ok(db.getLatestWithAverages(path, {pid}, pid)); + CHECK_THAT(snap.selectedAvg, WithinAbs(39.0 / 19.0, 1e-9)); + REQUIRE(snap.totals.contains(pid)); + CHECK_THAT(snap.totals.value(pid).onThread, WithinAbs(39.0 / 19.0, 1e-9)); + CHECK_THAT(snap.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. + const auto snap = ok(db.getLatestWithAverages( + path, {QStringLiteral("old.mod"), QStringLiteral("new.mod")}, {})); + CHECK(snap.totals.value(QStringLiteral("old.mod")).onThread == 0.0); + CHECK_THAT(snap.totals.value(QStringLiteral("new.mod")).onThread, + 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.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 00000000..5020a5c2 --- /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 00000000..798c97be --- /dev/null +++ b/tests/core/services/startup_impact_service/test_startup_impact.cpp @@ -0,0 +1,251 @@ +#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("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->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()); + CHECK_THAT(report->baseGameTime(), WithinAbs(11.7, 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("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 normalized package id") { + 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")); + + CHECK(report.find(QStringLiteral("missing.mod")) == nullptr); + CHECK(report.find(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 00000000..9c1971f0 --- /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_diagnostics_service.cpp b/tests/core/services/test_diagnostics_service.cpp new file mode 100644 index 00000000..d50a2b0e --- /dev/null +++ b/tests/core/services/test_diagnostics_service.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "core/context.hpp" +#include "core/mod_service.hpp" +#include "core/services/diagnostics_service.hpp" +#include "core/sort/models.hpp" +#include "services_support.hpp" + +namespace { + +QByteArray gzipText(const QByteArray& text) { + z_stream stream{}; + REQUIRE(deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, + 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) == Z_OK); + QByteArray out(compressBound(static_cast(text.size())) + 32, '\0'); + stream.next_in = reinterpret_cast(const_cast(text.data())); + stream.avail_in = static_cast(text.size()); + stream.next_out = reinterpret_cast(out.data()); + stream.avail_out = static_cast(out.size()); + const int status = deflate(&stream, Z_FINISH); + const auto written = out.size() - static_cast(stream.avail_out); + deflateEnd(&stream); + REQUIRE(status == Z_STREAM_END); + out.truncate(written); + return out; +} + +// Both rule databases must exist and be fresh, otherwise the service starts a +// background download that would outlive the test. +void writeDatabaseCaches(const QString& directory) { + REQUIRE(services_test::writeText( + QDir(directory).filePath(QStringLiteral("ModIdsToFix.xml")), + "
  • some.mod
  • ")); + REQUIRE(services_test::writeText( + QDir(directory).filePath(QStringLiteral("replacements.json.gz")), + gzipText(R"({"rules":[{"oldWorkshopId":"1","newName":"New",)" + R"("newAuthor":"Author","newPackageId":"new.mod"}]})"))); +} + +} // namespace + +TEST_CASE("unreadable community rules are skipped and clear loaded rules") { + services_test::EventLoop loop; + QTemporaryDir directory; + REQUIRE(directory.isValid()); + writeDatabaseCaches(directory.path()); + px::modrim::ConfigService configService(directory.path()); + + const QString rulesPath = + QDir(directory.path()).filePath(QStringLiteral("communityRules.json")); + REQUIRE(services_test::writeText( + rulesPath, R"({"rules":{"some.mod":{"loadTop":{"value":true}}}})")); + + px::modrim::AppConfig cfg; + cfg.paths.community_rules_file = rulesPath.toStdString(); + auto ctx = px::modrim::CoreContext::create(cfg, configService); + auto& diagnostics = ctx->diagnosticsService(); + + QStringList warnings; + const auto id = diagnostics.warningRaised.connect( + [&warnings](const QString& message) { warnings.append(message); }); + + services_test::runAsync(diagnostics.initialize()); + CHECK(diagnostics.communityRules().contains( + px::modrim::sort::PackageId(QStringLiteral("some.mod")))); + CHECK(warnings.isEmpty()); + + REQUIRE(services_test::writeText(rulesPath, R"({"rules": [1, 2)")); + + services_test::runAsync(diagnostics.initialize()); + CHECK(diagnostics.communityRules().isEmpty()); + CHECK(warnings.size() == 1); + CHECK(diagnostics.warningRaised.disconnect(id)); +} + +TEST_CASE("a malformed community rules file does not fail the mod load") { + services_test::EventLoop loop; + QTemporaryDir directory; + REQUIRE(directory.isValid()); + writeDatabaseCaches(directory.path()); + px::modrim::ConfigService configService(directory.path()); + + const QString rulesPath = + QDir(directory.path()).filePath(QStringLiteral("communityRules.json")); + REQUIRE(services_test::writeText(rulesPath, R"({"rules": [1, 2)")); + + px::modrim::AppConfig cfg; + cfg.paths.community_rules_file = rulesPath.toStdString(); + auto ctx = px::modrim::CoreContext::create(cfg, configService); + + CHECK(services_test::runAsync(ctx->modService().reload()).has_value()); + CHECK(ctx->diagnosticsService().communityRules().isEmpty()); +} diff --git a/tests/core/services/test_game_launcher.cpp b/tests/core/services/test_game_launcher.cpp new file mode 100644 index 00000000..f37efc0b --- /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 00000000..73a1c05c --- /dev/null +++ b/tests/core/services/test_metadata_cache.cpp @@ -0,0 +1,503 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "core/models/metadata/parsing.hpp" +#include "core/providers/folder.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("discovery rewrites Python cache dependencies in declaration order") { + 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")), R"( + + Python Cached Mod + author.cached +
  • 1.5
  • + +
  • z.firstFirst
  • +
  • a.secondSecond
  • +
    +
    +)"); + const ModCandidate candidate = candidateFor(modDir); + const QString dbPath = metadataCachePath(root.path()); + const QString connection = QStringLiteral("python-metadata-cache-fixture"); + { + QSqlDatabase db = + QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), connection); + db.setDatabaseName(dbPath); + REQUIRE(db.open()); + QSqlQuery query(db); + REQUIRE(query.exec(QStringLiteral(R"( +CREATE TABLE 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 +))"))); + REQUIRE(query.exec(QStringLiteral("PRAGMA user_version = 2"))); + REQUIRE(query.prepare( + QStringLiteral("INSERT INTO mod_metadata VALUES (?, ?, ?, ?, ?, ?)"))); + query.addBindValue(normalizePath(modDir)); + query.addBindValue(normalizePath(candidate.aboutXmlPath)); + query.addBindValue(candidate.mtime); + query.addBindValue(candidate.size); + query.addBindValue(QStringLiteral("1.5")); + query.addBindValue(QStringLiteral(R"({ + "name": "Python Cached Mod", + "package_id": "author.cached", + "authors": [], + "mod_version": "", + "mod_icon_path": null, + "steam_app_id": -1, + "url": "", + "description": "", + "supported_versions": ["1.5"], + "valid": true, + "mtime": %1, + "about_rules": { + "load_before": [], + "load_after": [], + "incompatible_with": [], + "dependencies": { + "z.first": { + "name": "First", "package_id": "z.first", "workshop_url": "", + "alternative_package_ids": [] + }, + "a.second": { + "name": "Second", "package_id": "a.second", "workshop_url": "", + "alternative_package_ids": [] + } + } + } +})") + .arg(QString::number(candidate.mtime, 'f', 3))); + REQUIRE(query.exec()); + query.finish(); + + MetadataCache cache(dbPath); + const CacheLookup legacy = + runAsync(cache.getMods({candidate}, QStringLiteral("1.5"))); + CHECK(legacy.cached.isEmpty()); + REQUIRE(legacy.missing.size() == 1); + FolderModProvider provider(QStringLiteral("local"), + QStringLiteral("#2ecc71"), modsRoot, false); + for (int pass = 0; pass < 2; ++pass) { + const auto discovered = + runAsync(provider.discover(QStringLiteral("1.5"), nullptr, &cache)); + REQUIRE(discovered.has_value()); + REQUIRE(discovered->contains(modDir)); + const auto mod = + std::dynamic_pointer_cast((*discovered)[modDir]); + REQUIRE(mod != nullptr); + REQUIRE(mod->aboutRules.dependencies.size() == 2); + CHECK(mod->aboutRules.dependencies[0].packageId.str() == + QStringLiteral("z.first")); + CHECK(mod->aboutRules.dependencies[1].packageId.str() == + QStringLiteral("a.second")); + } + + REQUIRE( + query.exec(QStringLiteral("SELECT payload_json FROM mod_metadata"))); + REQUIRE(query.next()); + const QJsonObject payload = + QJsonDocument::fromJson(query.value(0).toByteArray()).object(); + const QJsonValue dependencies = payload.value(QStringLiteral("about_rules")) + .toObject() + .value(QStringLiteral("dependencies")); + REQUIRE(dependencies.isArray()); + const QJsonArray array = dependencies.toArray(); + REQUIRE(array.size() == 2); + CHECK(array[0].toObject().value(QStringLiteral("package_id")).toString() == + QStringLiteral("z.first")); + CHECK(array[1].toObject().value(QStringLiteral("package_id")).toString() == + QStringLiteral("a.second")); + } + QSqlDatabase::removeDatabase(connection); +} + +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())); + FolderModProvider provider(QStringLiteral("local"), QStringLiteral("#2ecc71"), + modsRoot, false); + + auto first = + runAsync(provider.discover(QStringLiteral("1.5"), nullptr, &cache)); + 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"), nullptr, &cache)); + REQUIRE(cached.has_value()); + CHECK((*cached)[modDir]->name == QStringLiteral("Sample Mod One")); + auto forced = + runAsync(provider.discover(QStringLiteral("1.5"), nullptr, &cache, 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"), nullptr, &cache)); + 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())); + FolderModProvider local(QStringLiteral("local"), QStringLiteral("#2ecc71"), + localRoot, false); + FolderModProvider downloaded(QStringLiteral("downloaded"), + QStringLiteral("#3498db"), downloadedRoot, true); + + for (int pass = 0; pass < 2; ++pass) { + auto localMods = + runAsync(local.discover(QStringLiteral("1.5"), nullptr, &cache)); + auto steamMods = + runAsync(downloaded.discover(QStringLiteral("1.5"), nullptr, &cache)); + 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 00000000..04d2d59d --- /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 00000000..c0075056 --- /dev/null +++ b/tests/core/services/test_update_service.cpp @@ -0,0 +1,186 @@ +#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}, + {" v1.2.3 ", "1.2.2", true}, + }; + for (const auto& [tag, current, expected] : cases) { + CAPTURE(tag, current); + CHECK(isNewer(QString::fromLatin1(tag), QString::fromLatin1(current)) == + expected); + } +} + +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 00000000..6f38b636 --- /dev/null +++ b/tests/core/sort/test_community.cpp @@ -0,0 +1,60 @@ +#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); +} + +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 00000000..af9d00d5 --- /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 00000000..6cafb232 --- /dev/null +++ b/tests/core/sort/test_tiers.cpp @@ -0,0 +1,93 @@ +#include +#include +#include + +#include + +#include "core/sort/tiers.hpp" + +using px::modrim::sort::assignTiers; +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("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 00000000..c769dc67 --- /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 00000000..0d22f74f --- /dev/null +++ b/tests/core/test_config.cpp @@ -0,0 +1,280 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/config.hpp" +#include "core/constants.hpp" +#include "test_support.hpp" + +namespace config_test { + +using namespace px::modrim; +using test_support::readBytes; +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()); + const auto app = service.load(kConfigFile); + REQUIRE(app.has_value()); + + REQUIRE(*app == 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()); + const auto app = service.load(kConfigFile); + REQUIRE(app.has_value()); + + REQUIRE(*app == 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 00000000..665b8a0d --- /dev/null +++ b/tests/core/test_context.cpp @@ -0,0 +1,205 @@ +#include +#include +#include +#include +#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) { + auto mod = std::make_shared(); + mod->name = packageId; + mod->packageId = CaseInsensitiveStr(packageId); + mod->providerId = QStringLiteral("local"); + 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()); +} + +// Keeps QtConcurrent jobs queued: the global pool is reduced to one thread and +// that thread is blocked until release(). A sort started in between cannot +// finish, so its coroutine is still suspended when the test mutates the mods. +class GatedGlobalPool { + public: + GatedGlobalPool() : m_pool(QThreadPool::globalInstance()) { + m_previousMax = m_pool->maxThreadCount(); + m_pool->setMaxThreadCount(1); + m_blocker = QtConcurrent::run([this] { + m_started.release(); + m_gate.acquire(); + }); + m_started.acquire(); + } + + ~GatedGlobalPool() { + release(); + m_blocker.waitForFinished(); + m_pool->setMaxThreadCount(m_previousMax); + } + + GatedGlobalPool(const GatedGlobalPool&) = delete; + GatedGlobalPool& operator=(const GatedGlobalPool&) = delete; + + void release() { m_gate.release(); } + + private: + QThreadPool* m_pool; + int m_previousMax = 0; + QSemaphore m_started; + QSemaphore m_gate; + QFuture m_blocker; +}; + +} // 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("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 result = runAsync(ctx->autoSort()); + CHECK(result.count == 2); + CHECK(result.elapsedMs >= 0.0); + CHECK_FALSE(result.stale); + REQUIRE(ctx->activeUuids().size() == 2); + CHECK(ctx->activeUuids().indexOf(QStringLiteral("uuid-b")) < + ctx->activeUuids().indexOf(QStringLiteral("uuid-a"))); +} + +TEST_CASE("autoSort keeps the current order when the active list changes") { + EventLoop loop; + auto ctx = makeContext(); + ctx->load({{QStringLiteral("uuid-a"), about(QStringLiteral("mod.a"))}, + {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, + {QStringLiteral("uuid-a")}); + + GatedGlobalPool pool; + auto sorting = ctx->autoSort(); + REQUIRE_FALSE(sorting.isReady()); + ctx->setActive({QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); + pool.release(); + + const auto result = runAsync(std::move(sorting)); + CHECK(result.stale); + CHECK(ctx->activeUuids() == + QStringList{QStringLiteral("uuid-a"), QStringLiteral("uuid-b")}); +} + +TEST_CASE("autoSort keeps the current order when the sort settings change") { + EventLoop loop; + auto ctx = makeContext(); + ctx->load({{QStringLiteral("uuid-a"), about(QStringLiteral("mod.a"))}, + {QStringLiteral("uuid-b"), about(QStringLiteral("mod.b"))}}, + {QStringLiteral("uuid-a")}); + + GatedGlobalPool pool; + auto sorting = ctx->autoSort(); + REQUIRE_FALSE(sorting.isReady()); + AppConfig cfg = ctx->config(); + cfg.sort.use_alternative_package_ids = false; + ctx->updateConfig(std::move(cfg)); + pool.release(); + + const auto result = runAsync(std::move(sorting)); + CHECK(result.stale); + CHECK(ctx->activeUuids() == QStringList{QStringLiteral("uuid-a")}); +} diff --git a/tests/core/test_loading.cpp b/tests/core/test_loading.cpp new file mode 100644 index 00000000..9da95e10 --- /dev/null +++ b/tests/core/test_loading.cpp @@ -0,0 +1,72 @@ +#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.step(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.step(29); + REQUIRE(emissions.last() == Emission{QStringLiteral("p"), 28, 29, 100}); +} + +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 00000000..01d285ff --- /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, QStringLiteral("schema")) + .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, QStringLiteral("schema")) + .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, QStringLiteral("schema")); + 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, QStringLiteral("schema")) + .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, QStringLiteral("schema")) + .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 00000000..c6e9d4b3 --- /dev/null +++ b/tests/core/test_mods_config.cpp @@ -0,0 +1,436 @@ +#include +#include +#include +#include +#include + +#include +#include + +#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"); +} + +#ifndef Q_OS_WIN +TEST_CASE("snapshot discovery and pruning match Python's POSIX glob") { + QTemporaryDir dir; + REQUIRE(dir.isValid()); + const QString snapshotsDir = dir.filePath(QStringLiteral("snapshots")); + REQUIRE(QDir().mkpath(snapshotsDir)); + const QString pythonSnapshot = + QDir(snapshotsDir) + .filePath( + QStringLiteral("ModsConfig_20260922_100000_123456_001.xml")); + const QString lowerPrefix = + QDir(snapshotsDir) + .filePath(QStringLiteral("modsconfig_20260922_100001_123456.xml")); + const QString upperSuffix = + QDir(snapshotsDir) + .filePath(QStringLiteral("ModsConfig_20260922_100002_123456.XML")); + const QString snapshotDirectory = + QDir(snapshotsDir) + .filePath(QStringLiteral("ModsConfig_20260922_100003_123456.xml")); + REQUIRE(writeBytes(pythonSnapshot, kSampleXml)); + REQUIRE(writeBytes(lowerPrefix, kSampleXml)); + REQUIRE(writeBytes(upperSuffix, kSampleXml)); + REQUIRE(QDir().mkpath(snapshotDirectory)); + + REQUIRE(getSnapshots(snapshotsDir) == QStringList{pythonSnapshot}); + const auto parsed = parseModsConfig(pythonSnapshot); + REQUIRE(parsed.has_value()); + REQUIRE(parsed->version == QStringLiteral("1.6.4871 rev600")); + + const QString configPath = dir.filePath(QStringLiteral("ModsConfig.xml")); + REQUIRE(writeBytes(configPath, + "1.6" + "
  • mod.current
  • " + "
    ")); + const auto created = createSnapshot(configPath, snapshotsDir, 1); + REQUIRE(created.has_value()); + REQUIRE(created->has_value()); + REQUIRE(getSnapshots(snapshotsDir) == QStringList{**created}); + REQUIRE_FALSE(QFileInfo::exists(pythonSnapshot)); + REQUIRE(readBytes(lowerPrefix) == kSampleXml); + REQUIRE(readBytes(upperSuffix) == kSampleXml); + REQUIRE(QFileInfo(snapshotDirectory).isDir()); +} +#endif + +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 00000000..d980585c --- /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 00000000..5325c743 --- /dev/null +++ b/tests/core/test_plugin_config.cpp @@ -0,0 +1,63 @@ +#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}); +} diff --git a/tests/core/test_support.hpp b/tests/core/test_support.hpp new file mode 100644 index 00000000..3aa06e78 --- /dev/null +++ b/tests/core/test_support.hpp @@ -0,0 +1,61 @@ +#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). +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 00000000..146997e9 --- /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_xml.cpp b/tests/core/test_xml.cpp new file mode 100644 index 00000000..0a2f81dd --- /dev/null +++ b/tests/core/test_xml.cpp @@ -0,0 +1,54 @@ +#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("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 00000000..972c847c --- /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 00000000..846a3748 --- /dev/null +++ b/tests/core/workshop/test_catalog.cpp @@ -0,0 +1,645 @@ +#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/models/metadata/structures.hpp" +#include "core/workshop/catalog.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()); + manager().notifyBusy(true); + co_await *gate; + isBusy = false; + active.clear(); + manager().notifyBusy(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, 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, 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); + + 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, 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; + + DownloadPlan plan{ + .to_download = {QStringLiteral("2")}, + .already_current = {QStringLiteral("1")}, + .is_complete = true, + }; + const auto result = run(fixture.downloads.downloadMods(plan.to_download)); + 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, fixture.downloads, + [&] { return fixture.mods; }); + REQUIRE( + run(first.cached>( + QStringLiteral("installed"), produce, [](const auto&) {}, 300.0, + /*persist=*/true)) + .has_value()); + } + + WorkshopCatalog second(*fixture.config, 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 00000000..6d1521fb --- /dev/null +++ b/tests/core/workshop/test_client.cpp @@ -0,0 +1,465 @@ +#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->size() == 204); + CHECK(result->front().id == "1"); + + const auto empty = run(client.modsBatch({})); + REQUIRE(empty.has_value()); + CHECK(empty->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 00000000..81dab650 --- /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 00000000..158acb75 --- /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_checker/__init__.py b/tests/test_checker/__init__.py deleted file mode 100644 index 9d48db4f..00000000 --- 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 2b74fe76..00000000 --- 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 996d4886..00000000 --- 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 62d5f778..00000000 --- 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 6857c8c5..00000000 --- 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 c4c96650..00000000 --- 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 f7ad2748..00000000 --- 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 45b0ed97..00000000 --- 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 a443a685..00000000 --- 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 e69de29b..00000000 diff --git a/tests/test_metadata/data/local_mods/valid_mod/About/About.xml b/tests/test_metadata/data/local_mods/valid_mod/About/About.xml deleted file mode 100644 index c162c115..00000000 --- a/tests/test_metadata/data/local_mods/valid_mod/About/About.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Test Mod - TestAuthor - test.mod - A test mod for unit testing. - -
  • 1.5
  • -
    - 1.0.0 - -
  • ludeon.rimworld
  • -
    -
    diff --git a/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml b/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml deleted file mode 100644 index c162c115..00000000 --- a/tests/test_metadata/data/rimworld_game/Data/ludeon.rimworld/About/About.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Test Mod - TestAuthor - test.mod - A test mod for unit testing. - -
  • 1.5
  • -
    - 1.0.0 - -
  • ludeon.rimworld
  • -
    -
    diff --git a/tests/test_metadata/data/valid_mod/About/About.xml b/tests/test_metadata/data/valid_mod/About/About.xml deleted file mode 100644 index c162c115..00000000 --- a/tests/test_metadata/data/valid_mod/About/About.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - Test Mod - TestAuthor - test.mod - A test mod for unit testing. - -
  • 1.5
  • -
    - 1.0.0 - -
  • ludeon.rimworld
  • -
    -
    diff --git a/tests/test_metadata/test_metadata_cache.py b/tests/test_metadata/test_metadata_cache.py deleted file mode 100644 index 68987e2a..00000000 --- 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 8a8e42e0..00000000 --- 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 f31c13b4..00000000 --- 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 baca75aa..00000000 --- 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 e7a4144f..00000000 --- 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 bcfe1b4b..00000000 --- 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 065b310f..00000000 --- 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 47e08261..00000000 --- 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 e69de29b..00000000 diff --git a/tests/test_organizer/test_db.py b/tests/test_organizer/test_db.py deleted file mode 100644 index 5a1fe1f1..00000000 --- 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 011c496b..00000000 --- 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 8087955e..00000000 --- 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 9f5f0bf7..00000000 --- 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 2660f7d7..00000000 --- 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 0bc834df..00000000 --- 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 4d1cd1cf..00000000 --- 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 ba7608c7..00000000 --- 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 632010a6..00000000 --- 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 e9ec004a..00000000 --- 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 8bb18b79..00000000 --- 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 1967dfb4..00000000 --- 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 1d340c16..00000000 --- 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 2539c4b9..00000000 --- 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 2291936a..00000000 --- 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 6c28f505..00000000 --- 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 7deadbc4..00000000 --- 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 023662d6..00000000 --- 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 1cc916b1..00000000 --- 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 d69fce3a..00000000 --- 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 5c3d08b4..00000000 --- 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 5299f533..00000000 --- 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 0908ba39..00000000 --- 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 be675b45..00000000 --- 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 225a4e9c..00000000 --- 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 9540b689..00000000 --- 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 f6d0dda5..00000000 --- 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 7aa8dd08..00000000 --- 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 00fa86e8..00000000 --- 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 fc2c18b3..00000000 --- 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 7b2822b2..00000000 --- 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 1e5ea3ba..00000000 --- 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 bd97e971..00000000 --- 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 f4a98ebc..00000000 --- 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 765a398b..00000000 --- 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 f047cab6..00000000 --- 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 1a6592d9..00000000 --- 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 bdbed936..00000000 --- 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 525171eb..00000000 --- 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 e28d4f67..00000000 --- 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 dca61540..00000000 --- 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 2e290c66..00000000 --- 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 5104d390..00000000 --- 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 00000000..3d32e942 --- /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 00000000..0ed8e188 --- /dev/null +++ b/tests/ui/test_dialog_chrome.cpp @@ -0,0 +1,36 @@ +#include +#include +#include + +#include + +#include "ui/components/dialog_chrome.hpp" + +namespace { + +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") { + 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") { + 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 00000000..3227448e --- /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(); + 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 00000000..4c9a6052 --- /dev/null +++ b/tests/ui/test_main_window.cpp @@ -0,0 +1,264 @@ +#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 { +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) { + 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 00000000..89fb7269 --- /dev/null +++ b/tests/ui/test_menu_bar.cpp @@ -0,0 +1,59 @@ +#include +#include +#include +#include +#include + +#include + +#include "ui/window/actions.hpp" +#include "ui/window/menu_bar.hpp" + +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]") { + 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]") { + 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 00000000..9e9313ca --- /dev/null +++ b/tests/ui/test_mod_info_data.cpp @@ -0,0 +1,387 @@ +#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) { + 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)); + } +}; + +ModInfo buildInfo(const ModInfoContext& context, const ListedMod& mod, + const QList& issues = {}) { + auto data = buildModInfo(*context.ctx, mod); + updateActiveState(context.ctx->activeUuids(), mod.uuid(), data); + updateIssues(issues, data.info); + return data; +} + +} // 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 = buildInfo(context, *main).info.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(buildInfo(context, *main).info.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(buildInfo(context, *target).info.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{"Cat", std::nullopt, false}; + const auto data = buildInfo(context, *main, {issue}); + const auto& info = data.info; + 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(data.description.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 data = buildInfo(context, *mod); + const auto& info = data.info; + 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(data.description.isEmpty()); + CHECK(info.value("neededBy").toList().isEmpty()); +} + +TEST_CASE("Mod info issue updates leave metadata and dependency state alone", + "[ui][mod-info]") { + auto dep = makeMod("Dep", "d.dep"); + auto main = makeMod("Main", "m.main"); + addDependency(*main, "d.dep"); + main->description = "https://a.com/x"; + ModInfoContext context({dep, main}, {main->uuid()}); + const auto data = buildInfo(context, *main); + auto info = data.info; + updateIssues({view::ModIssueView{"Cat", QStringLiteral("Detail"), true}}, + info); + const auto status = info.value("status").toMap(); + CHECK(status.value("level").toString() == "error"); + CHECK(status.value("title").toString() == "1 error"); + const QVariantList expectedIssues{ + QVariantMap{{"title", "Cat"}, {"detail", "Detail"}, {"isError", true}}}; + CHECK(status.value("issues").toList() == expectedIssues); + CHECK(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 1); + CHECK(info.value("needs") == data.info.value("needs")); + CHECK(info.value("details") == data.info.value("details")); + CHECK(info.value("author") == data.info.value("author")); + CHECK(info.value("conflicts") == data.info.value("conflicts")); +} + +TEST_CASE("Mod info active updates refresh positions states and conflicts", + "[ui][mod-info]") { + auto dep = makeMod("Dep", "d.dep"); + auto alpha = makeMod("Alpha", "c.a"); + auto main = makeMod("Main", "m.main"); + addDependency(*main, "d.dep"); + main->aboutRules.incompatibleWith = CaseInsensitiveSet(QStringList{"c.a"}); + ModInfoContext context({dep, alpha, main}, {main->uuid()}); + auto data = buildModInfo(*context.ctx, *main); + updateActiveState(context.ctx->activeUuids(), main->uuid(), data); + CHECK(data.info.value("status").toMap().value("position").toInt() == 1); + CHECK(data.info.value("conflicts").toStringList().isEmpty()); + const auto description = data.description; + updateActiveState({alpha->uuid(), dep->uuid(), main->uuid()}, main->uuid(), + data); + const auto status = data.info.value("status").toMap(); + CHECK(status.value("active").toBool()); + CHECK(status.value("position").toInt() == 3); + CHECK(status.value("total").toInt() == 3); + CHECK(data.info.value("conflicts").toStringList() == QStringList{"Alpha"}); + CHECK(data.info.value("needs").toList() == + QVariantList{QVariantMap{ + {"name", "d.dep"}, {"uuid", dep->uuid()}, {"state", "active"}}}); + CHECK(data.description == description); +} + +TEST_CASE("Mod info metadata rebuild refreshes description and authors", + "[ui][mod-info]") { + auto mod = makeMod("Old", "r.mod"); + mod->description = "old"; + mod->authors = {"Ann"}; + ModInfoContext context({mod}, {}); + const auto before = buildInfo(context, *mod); + mod->name = "New"; + mod->description = "new https://a.com/x"; + mod->authors = {"Ann", "Bob"}; + const auto after = buildInfo(context, *mod); + CHECK(before.description == "old"); + CHECK(after.description.contains("")); + CHECK(after.info.value("name").toString() == "New"); + CHECK(after.info.value("author").toString() == "Ann, Bob"); +} + +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 00000000..372c2acc --- /dev/null +++ b/tests/ui/test_mod_list_model.cpp @@ -0,0 +1,141 @@ +#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); + 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)); + 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"); +} +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 00000000..40202106 --- /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->model()->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 00000000..37129bf5 --- /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 00000000..24a0c42e --- /dev/null +++ b/tests/ui/test_mod_tree_model.cpp @@ -0,0 +1,421 @@ +#include +#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.select({}); + 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(std::move(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 00000000..c840902f --- /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 00000000..0ca29020 --- /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 00000000..021d8ae4 --- /dev/null +++ b/tests/ui/test_organizer_dialogs.cpp @@ -0,0 +1,20 @@ +#include +#include +#include +#include + +#include + +#include "ui/plugins/organizer/dialogs.hpp" + +using px::modrim::DeleteFolderDialog; + +TEST_CASE("delete folder dialog confirms with yes button") { + 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 00000000..1b60b0c7 --- /dev/null +++ b/tests/ui/test_organizer_rule_editor.cpp @@ -0,0 +1,412 @@ +#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; + +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 { + 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 RuleSpec& 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 00000000..20f743e2 --- /dev/null +++ b/tests/ui/test_procedural_preview.cpp @@ -0,0 +1,49 @@ +#include +#include + +#include + +#include "ui/components/procedural_preview.hpp" + +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") { + 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") { + 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_restart.cpp b/tests/ui/test_restart.cpp new file mode 100644 index 00000000..56acd06c --- /dev/null +++ b/tests/ui/test_restart.cpp @@ -0,0 +1,115 @@ +#include +#include + +#include + +#include "ui/window/restart.hpp" + +using namespace px::modrim; + +namespace { + +const QString kAppImage = "/home/user/Apps/PxModRim.AppImage"; +const QString kAppDir = "/tmp/.mount_PxModR"; +const QString kMountedExecutable = "/tmp/.mount_PxModR/usr/bin/pxmodrim"; + +// A fake filesystem: the files that exist, and canonical paths of every +// existing path (missing paths canonicalise to an empty string). +struct FakeFiles { + QSet files{kAppImage, kMountedExecutable, + "/tmp/.mount_PxModR-other/usr/bin/pxmodrim", + "/usr/bin/pxmodrim"}; + QHash canonical{ + {kAppImage, kAppImage}, + {kAppDir, kAppDir}, + {"/tmp/.mount_PxModR/", kAppDir}, + {kMountedExecutable, kMountedExecutable}, + {"/tmp/link/pxmodrim", kMountedExecutable}, + {"/tmp/.mount_PxModR-other/usr/bin/pxmodrim", + "/tmp/.mount_PxModR-other/usr/bin/pxmodrim"}, + {"/usr/bin/pxmodrim", "/usr/bin/pxmodrim"}}; + + QString programFor(const QProcessEnvironment& environment, + const QString& executable) const { + return restartProgram( + environment, executable, + [this](const QString& path) { return files.contains(path); }, + [this](const QString& path) { return canonical.value(path); }); + } +}; + +QProcessEnvironment appImageEnvironment(const QString& appImage = kAppImage, + const QString& appDir = kAppDir) { + QProcessEnvironment environment; + environment.insert("APPIMAGE", appImage); + environment.insert("APPDIR", appDir); + return environment; +} + +} // namespace + +TEST_CASE("Restart preserves the program and original arguments") { + const QStringList arguments{"pxmodrim", "--ui-scale", "175", + "-platform", "offscreen", "--unknown", + "path with spaces"}; + const auto command = restartCommand("/opt/PxModRim/pxmodrim", arguments, + QProcessEnvironment{}, false); + + CHECK(command.program == "/opt/PxModRim/pxmodrim"); + CHECK(command.arguments == arguments.sliced(1)); + CHECK(restartCommand("pxmodrim", {"pxmodrim"}, QProcessEnvironment{}, false) + .arguments.isEmpty()); +} + +TEST_CASE("Restart relaunches the AppImage that runs this executable") { + const FakeFiles files; + + CHECK(files.programFor(appImageEnvironment(), kMountedExecutable) == + kAppImage); + CHECK(files.programFor(appImageEnvironment(), "/tmp/link/pxmodrim") == + kAppImage); + CHECK(files.programFor(appImageEnvironment(kAppImage, kAppDir + "/"), + kMountedExecutable) == kAppImage); +} + +TEST_CASE("Restart ignores an AppImage that does not run this executable") { + const FakeFiles files; + const auto keepsExecutable = [&](const QProcessEnvironment& environment, + const QString& executable) { + return files.programFor(environment, executable) == executable; + }; + + // Inherited from a parent AppImage while running from a normal install. + CHECK(keepsExecutable(appImageEnvironment(), "/usr/bin/pxmodrim")); + CHECK(keepsExecutable(appImageEnvironment(), + "/tmp/.mount_PxModR-other/usr/bin/pxmodrim")); + CHECK(keepsExecutable(appImageEnvironment(), kAppDir)); + CHECK(keepsExecutable(appImageEnvironment("/home/user/Apps/Missing.AppImage"), + kMountedExecutable)); + CHECK(keepsExecutable(appImageEnvironment(""), kMountedExecutable)); + CHECK( + keepsExecutable(appImageEnvironment(kAppImage, ""), kMountedExecutable)); + CHECK(keepsExecutable(appImageEnvironment(kAppImage, "/tmp/missing"), + kMountedExecutable)); + CHECK(keepsExecutable(QProcessEnvironment{}, kMountedExecutable)); +} + +TEST_CASE("Restart drops only an application-generated Qt scale") { + QProcessEnvironment environment; + environment.insert("QT_SCALE_FACTOR", "1.75"); + environment.insert("PX_UI_SCALE", "150"); + environment.insert("PATH", "/usr/bin"); + + auto expected = environment; + expected.remove("QT_SCALE_FACTOR"); + CHECK(restartCommand("pxmodrim", {"pxmodrim"}, environment, false) + .environment == expected); + + CHECK( + restartCommand("pxmodrim", {"pxmodrim"}, environment, true).environment == + environment); + environment.insert("QT_SCALE_FACTOR", ""); + CHECK( + restartCommand("pxmodrim", {"pxmodrim"}, environment, true).environment == + environment); +} diff --git a/tests/ui/test_restore_snapshot_dialog.cpp b/tests/ui/test_restore_snapshot_dialog.cpp new file mode 100644 index 00000000..3f36d56a --- /dev/null +++ b/tests/ui/test_restore_snapshot_dialog.cpp @@ -0,0 +1,287 @@ +#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; + +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]") { + 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]") { + 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]") { + 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]") { + 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]") { + 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]") { + 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]") { + 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]") { + 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 00000000..4be10697 --- /dev/null +++ b/tests/ui/test_settings_panel.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "test_support.hpp" +#include "ui/components/svg_provider.hpp" +#include "ui/panels/settings_panel.hpp" +#include "ui/theme/qml_theme.hpp" +namespace { +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") { + 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 00000000..9264cbf2 --- /dev/null +++ b/tests/ui/test_sidebar_model.cpp @@ -0,0 +1,42 @@ +#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"}}}); + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + QSignalSpy reset(&model, &QAbstractItemModel::modelReset); + model.updateEntries({{SidebarEntry::Kind::kProvider, "Local", {"a", "b"}}}); + 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)->label == "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 00000000..21b6b695 --- /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 00000000..29609a4a --- /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 00000000..7eff46d0 --- /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.cpp b/tests/ui/test_toast.cpp new file mode 100644 index 00000000..7918d5b2 --- /dev/null +++ b/tests/ui/test_toast.cpp @@ -0,0 +1,52 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "ui/components/toast.hpp" + +namespace { + +using px::modrim::Toast; +using px::modrim::ToastLevel; +using px::modrim::ToastManager; + +// 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") { + 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") { + QWidget parent; + ToastManager manager(&parent); + manager.info(QStringLiteral("gone soon"), 50); + REQUIRE(manager.layout()->count() == 1); + + waitMs(600); + + REQUIRE(manager.layout()->count() == 0); +} diff --git a/tests/ui/test_ui_prefs.cpp b/tests/ui/test_ui_prefs.cpp new file mode 100644 index 00000000..76b7be11 --- /dev/null +++ b/tests/ui/test_ui_prefs.cpp @@ -0,0 +1,136 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "core/config.hpp" +#include "ui/config.hpp" +#include "ui/ui_prefs.hpp" + +using namespace px::modrim; + +TEST_CASE("Interface scale clamps percentages and maps them to Qt factors") { + CHECK(UiPrefs{}.ui_scale == 100); + CHECK(clampUiScale(std::numeric_limits::min()) == 100); + CHECK(clampUiScale(137) == 137); + CHECK(clampUiScale(std::numeric_limits::max()) == 250); + + CHECK(uiScaleFactor(100) == "1"); + CHECK(uiScaleFactor(125) == "1.25"); + CHECK(uiScaleFactor(137) == "1.37"); + CHECK(uiScaleFactor(150) == "1.5"); + CHECK(uiScaleFactor(200) == "2"); + CHECK(uiScaleFactor(250) == "2.5"); +} + +TEST_CASE( + "Startup interface scale resolves overrides without persisting them") { + UiPrefs prefs; + prefs.ui_scale = 125; + const auto saved = resolveUiScale(prefs.ui_scale); + CHECK(saved.scale == 125); + CHECK(saved.source == ScaleSource::kSaved); + + const auto env = resolveUiScale(prefs.ui_scale, {}, "150"); + CHECK(env.scale == 150); + CHECK(env.source == ScaleSource::kEnv); + + const auto cli = resolveUiScale(prefs.ui_scale, "175", "150"); + CHECK(cli.scale == 175); + CHECK(cli.source == ScaleSource::kCli); + + const auto qt = resolveUiScale(prefs.ui_scale, "175", "150", "1.5"); + CHECK(qt.scale == 150); + CHECK(qt.source == ScaleSource::kQtEnv); + CHECK(qt.qtScaleFactor == "1.5"); + CHECK(resolveUiScale(125, {}, {}, "1.755").scale == 175.5); + CHECK(resolveUiScale(125, "175", "150", "3").scale == 300); + CHECK(resolveUiScale(125, "175", "150", QStringLiteral("")).source == + ScaleSource::kQtEnv); + CHECK(resolveUiScale(125, "175", "150", "bad").scale == 100); + CHECK(prefs.ui_scale == 125); + + const auto invalidCli = resolveUiScale(125, "bad", "150"); + CHECK(invalidCli.scale == 150); + CHECK(invalidCli.source == ScaleSource::kEnv); + const auto invalidBoth = resolveUiScale(125, "1.5", "bad"); + CHECK(invalidBoth.scale == 125); + CHECK(invalidBoth.source == ScaleSource::kSaved); + CHECK(resolveUiScale(125, QStringLiteral(""), "150").source == + ScaleSource::kEnv); + CHECK(resolveUiScale(125, "1_75", "150").source == ScaleSource::kEnv); + + CHECK(resolveUiScale(50).scale == 100); + CHECK(resolveUiScale(500).scale == 250); + CHECK(resolveUiScale(125, "-50", "150").scale == 100); + CHECK(resolveUiScale(125, "-999999999999999999999999").scale == 100); + CHECK(resolveUiScale(125, "999", "150").scale == 250); + CHECK(resolveUiScale(125, "999999999999999999999999").scale == 250); + CHECK(resolveUiScale(125, {}, "0").scale == 100); + CHECK(resolveUiScale(125, " 175 ").scale == 175); + CHECK(resolveUiScale(125, "+175").scale == 175); +} + +TEST_CASE("Interface scale arguments leave unknown and Qt arguments alone") { + CHECK(uiScaleArgument({"pxmodrim", "--ui-scale", "175"}) == "175"); + CHECK(uiScaleArgument({"pxmodrim", "--ui-scale=150"}) == "150"); + CHECK(uiScaleArgument({"pxmodrim", "-platform", "offscreen", "--unknown"}) + .isNull()); + CHECK(uiScaleArgument({"pxmodrim", "--", "--ui-scale=150"}).isNull()); + CHECK(uiScaleArgument({"pxmodrim", "--ui-scale=125", "--ui-scale", "175"}) == + "175"); + CHECK(uiScaleArgument({"pxmodrim", "--ui-scale"}).isEmpty()); + CHECK_FALSE(uiScaleArgument({"pxmodrim", "--ui-scale"}).isNull()); + CHECK(uiScaleArgument({"pxmodrim", "--ui-scale="}).isEmpty()); + CHECK_FALSE(uiScaleArgument({"pxmodrim", "--ui-scale="}).isNull()); + CHECK(resolveUiScale(200, + uiScaleArgument({"pxmodrim", "--ui-scale", "--unknown"}), + "150") + .scale == 150); +} + +TEST_CASE("A saved interface scale loads without discarding other prefs") { + QTemporaryDir directory; + REQUIRE(directory.isValid()); + const ConfigService config(directory.path()); + // An empty scale means the key is absent, as in files from older versions. + const std::pair cases[] = { + {"", 100}, + {"175", 175}, + {"137", 137}, + {"150.0", 150}, + {"1.5e2", 150}, + {"99999999999999999999999", 250}, + {"-99999999999999999999999", 100}, + {"1.5", 100}, + {"150.5", 100}, + {R"("175")", 100}, + {R"("150.0")", 100}, + {"null", 100}, + {"true", 100}, + {"[]", 100}, + {"{}", 100}, + }; + for (const auto& [scale, expected] : cases) { + CAPTURE(scale); + const std::string scaleMember = + scale.empty() ? std::string{} : R"("ui_scale": )" + scale + ", "; + const std::string text = + R"({"rail_collapsed": true, "skipped_update_tag": "v9.9.9", )" + + scaleMember + R"("schema_version": 1})"; + QFile file(QDir(directory.path()).filePath("ui_prefs.json")); + REQUIRE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(text.data(), static_cast(text.size())); + file.close(); + + const auto prefs = loadUiPrefs(config); + REQUIRE(prefs); + CHECK(prefs->rail_collapsed); + CHECK(prefs->skipped_update_tag == "v9.9.9"); + CHECK(prefs->ui_scale == expected); + } +} diff --git a/tests/ui/test_unity_rich_text.cpp b/tests/ui/test_unity_rich_text.cpp new file mode 100644 index 00000000..2418853a --- /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 00000000..ab87a5df --- /dev/null +++ b/tests/ui/test_views.cpp @@ -0,0 +1,54 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#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_window_geometry.cpp b/tests/ui/test_window_geometry.cpp new file mode 100644 index 00000000..942a305d --- /dev/null +++ b/tests/ui/test_window_geometry.cpp @@ -0,0 +1,40 @@ +#include + +#include "ui/window/geometry.hpp" + +using namespace px::modrim; + +TEST_CASE("Initial window geometry stays within its available screen") { + const QRect desktop(0, 0, 1920, 1080); + const QRect initial(260, 115, 1400, 850); + CHECK(fitWindowGeometry(initial, desktop) == initial); + + const QRect small(0, 0, 1280, 720); + const QRect shrunk = fitWindowGeometry(initial, small); + CHECK(shrunk.size() == QSize(1240, 680)); + CHECK(small.contains(shrunk)); + + const QRect floorScreen(0, 0, 920, 580); + const QRect floor = fitWindowGeometry(initial, floorScreen); + CHECK(floor.size() == QSize(900, 560)); + CHECK(floorScreen.contains(floor)); + + const QRect tinyScreen(0, 0, 800, 480); + const QRect tiny = fitWindowGeometry(initial, tinyScreen); + CHECK(tiny == tinyScreen); + + const QRect tooSmall(100, 100, 400, 300); + CHECK(fitWindowGeometry(tooSmall, desktop).size() == QSize(900, 560)); + + const QRect otherScreen(-1920, 100, 1920, 1080); + const QRect offscreen(4000, -2000, 1400, 850); + const QRect moved = fitWindowGeometry(offscreen, otherScreen); + CHECK(moved.size() == offscreen.size()); + CHECK(otherScreen.contains(moved)); + CHECK(moved.right() == otherScreen.right() - 20); + CHECK(moved.top() == otherScreen.top() + 20); + const QRect opposite = + fitWindowGeometry(QRect(-5000, 5000, 1400, 850), otherScreen); + CHECK(opposite.left() == otherScreen.left() + 20); + CHECK(opposite.bottom() == otherScreen.bottom() - 20); +} diff --git a/tests/ui/test_workshop_images.cpp b/tests/ui/test_workshop_images.cpp new file mode 100644 index 00000000..6616636a --- /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 00000000..f5010b49 --- /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 00000000..ca6b6508 --- /dev/null +++ b/tests/ui/test_workshop_models.cpp @@ -0,0 +1,396 @@ +#include +#include +#include +#include +#include + +#include + +#include "ui/panels/mod_info_data.hpp" +#include "ui/plugins/workshop/models.hpp" + +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 installed(const CatalogMod&) { return InstallState::kInstalled; } +InstallState outdated(const CatalogMod&) { return InstallState::kOutdated; } + +CardContext context(std::function state, + QString game = QStringLiteral("1.5")) { + CardContext value; + value.gameVersion = std::move(game); + value.installState = std::move(state); + return value; +} + +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; +} + +// Reads a one-row model through the QML roles, so assertions cover what the +// delegates see rather than the card struct. +class CardView { + public: + CardView(const CatalogEntry& entry, const CardContext& snapshot) + : m_entry(entry), m_card(makeCard(entry, snapshot)), m_model(&m_owner) { + m_model.setPage({m_card}, 1, std::nullopt); + } + + QVariant operator[](const char* name) const { + return m_model.data(m_model.index(0), role(m_model, name)); + } + QVariantMap detail() const { return cardDetail(m_card, m_entry); } + + private: + QObject m_owner; + CatalogEntry m_entry; + CatalogCard m_card; + CatalogListModel m_model; +}; +} // 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 auto ctx = context(outdated, "1.6"); + const QList cards{makeCard(CatalogEntry{mod("1")}, ctx), + makeCard(CatalogEntry{mod("2")}, ctx), + makeCard(CatalogEntry{mod("3")}, ctx)}; + int notifications = 0; + QObject::connect(&model, &CatalogListModel::changed, + [&] { ++notifications; }); + model.setPage({cards[0], cards[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)); + const QSet expected{ + "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"}; + REQUIRE(names == expected); + 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({cards[1], cards[2], cards[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"); + const auto ctx = context(missing, "1.5"); + auto cards = [&] { + return QList{makeCard(CatalogEntry{first}, ctx), + makeCard(CatalogEntry{second}, ctx), + makeCard(CatalogEntry{third}, ctx)}; + }; + model.setPage(cards(), 3, std::nullopt); + 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"; + model.setPage(cards(), 3, std::nullopt); + REQUIRE(resets == 0); + REQUIRE(changed == QList{1}); + REQUIRE(model.data(model.index(1), role(model, "title")).toString() == + "Renamed"); + auto reversed = cards(); + std::reverse(reversed.begin(), reversed.end()); + model.setPage(reversed, 3, std::nullopt); + REQUIRE(resets == 1); + model.refresh([](const CatalogCard& card) { + auto copy = card; + copy.queued = true; + return copy; + }); + REQUIRE(model.data(model.index(0), role(model, "queued")).toBool()); + REQUIRE(changed.back() == 0); + model.setPage({}, 0, std::nullopt); + changed.clear(); + model.refresh([](const CatalogCard& card) { return card; }); + REQUIRE(changed.isEmpty()); +} + +TEST_CASE("workshop paging append preserves existing rows and selection") { + QObject owner; + CatalogListModel model(&owner); + const auto ctx = context(missing, "1.5"); + model.setPage({makeCard(CatalogEntry{mod("1")}, ctx)}, 3, + QStringLiteral("page2")); + const QPersistentModelIndex selected(model.index(0)); + int resets = 0, insertions = 0; + QObject::connect(&model, &QAbstractItemModel::modelReset, [&] { ++resets; }); + QObject::connect(&model, &QAbstractItemModel::rowsInserted, + [&] { ++insertions; }); + model.setPage({makeCard(CatalogEntry{mod("2")}, ctx), + makeCard(CatalogEntry{mod("3")}, ctx)}, + 3, std::nullopt, true); + REQUIRE(resets == 0); + REQUIRE(insertions == 1); + REQUIRE(selected.isValid()); + REQUIRE(selected.row() == 0); + REQUIRE(model.data(selected, role(model, "itemId")).toString() == "1"); + REQUIRE(model.count() == 3); + REQUIRE_FALSE(model.hasMore()); +} + +TEST_CASE( + "workshop card state precedence resolves preparing then downloading then " + "queued") { + const auto value = mod(); + auto ctx = context(missing, "1.5"); + ctx.preparingIds = {"1"}; + ctx.downloadingIds = {"1"}; + ctx.queuedIds = {"1"}; + CardView preparing(value, ctx); + REQUIRE(preparing["queued"].toBool()); + REQUIRE(preparing["actionLabel"].toString() == "Preparing…"); + ctx.preparingIds.clear(); + CardView downloading(value, ctx); + REQUIRE(downloading["queued"].toBool()); + REQUIRE(downloading["actionLabel"].toString() == "Downloading"); + ctx.downloadingIds.clear(); + CardView queued(value, ctx); + REQUIRE(queued["queued"].toBool()); + REQUIRE(queued["actionLabel"].toString() == "Queued"); +} + +TEST_CASE("workshop installed mods ignore queue state but collections do not") { + const auto value = mod(); + auto ctx = context(installed, "1.5"); + ctx.downloadingIds = {"1"}; + CardView modCard(value, ctx); + REQUIRE_FALSE(modCard["queued"].toBool()); + REQUIRE(modCard["actionLabel"].toString() == "Installed"); + REQUIRE(modCard["state"].toString() == "installed"); + + CatalogCollection collection; + collection.id = "c"; + collection.member_ids = {"1", "2"}; + auto memberCtx = context(missing, "1.5"); + memberCtx.installedIds = {"1"}; + CardView partial(collection, memberCtx); + REQUIRE(partial["state"].toString() == "missing"); + REQUIRE(partial["actionLabel"].toString() == "Complete download"); + memberCtx.installedIds = {"1", "2"}; + CardView complete(collection, memberCtx); + REQUIRE(complete["state"].toString() == "installed"); + REQUIRE(complete["actionLabel"].toString() == "Installed"); + memberCtx.downloadingIds = {"2"}; + CardView downloading(collection, memberCtx); + REQUIRE(downloading["state"].toString() == "installed"); + REQUIRE(downloading["queued"].toBool()); + REQUIRE(downloading["actionLabel"].toString() == "Downloading"); + memberCtx.downloadingIds.clear(); + memberCtx.favouriteIds = {"c"}; + memberCtx.activeIds = {"1"}; + CardView favourite(collection, memberCtx); + REQUIRE(favourite["favourite"].toBool()); + REQUIRE_FALSE(favourite["active"].toBool()); +} + +TEST_CASE( + "workshop mod rows preserve state sizes versions descriptions and " + "compatibility") { + auto value = mod(); + CardView view(value, context(outdated, "1.6")); + REQUIRE(view["actionLabel"].toString() == "Update"); + REQUIRE(view["stateLabel"].toString() == "Update available"); + REQUIRE(view["incompatible"].toBool()); + REQUIRE(view["fileSize"].toString() == "1.0 MB"); + REQUIRE(view["description"].toString().isEmpty()); + REQUIRE(view.detail().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"}; + CardView sorted(value, context(missing, "1.5")); + REQUIRE(sorted["versions"].toString() == "1.2, 1.2.1, 1.5, 1.10"); + REQUIRE(sorted["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; + CardView compatible(value, context(missing, scenario.game)); + REQUIRE(compatible["compatLabel"].toString() == scenario.label); + } + value.author = {}; + value.file_size.reset(); + CardView unknown(value, context(missing, "")); + REQUIRE(unknown["author"].toString() == "Unknown author"); + REQUIRE(unknown["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; + CardView view(value, context(installed, "1.5")); + REQUIRE(view["votes"].toString() == scenario.approval); + REQUIRE(view["votesUp"].toString() == scenario.up); + REQUIRE(view["votesDown"].toString() == scenario.down); + REQUIRE(view["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; + }; + CardView view(value, context(neverInstall, "1.6")); + REQUIRE(view["previewUrl"].toString() == "https://images.test/own.png"); + REQUIRE(view["collage"].toStringList() == + QStringList{"https://images.test/m.png"}); + REQUIRE(view["votes"].toString() == "Unrated"); + REQUIRE(view["votesUp"].toString() == "0"); + REQUIRE(view["votesDown"].toString() == "0"); + REQUIRE_FALSE(view["active"].toBool()); + REQUIRE(view["fileSize"].toString() == "Unknown"); + REQUIRE(view["sourceLabel"].toString() == "PxModRim pick"); + REQUIRE(view["actionLabel"].toString() == "Download all"); + REQUIRE(view["memberCount"].toInt() == 1); + REQUIRE(view.detail().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; + CardView sized(value, context(neverInstall, "1.6")); + REQUIRE(sized["fileSize"].toString() == "3.0 MB"); + REQUIRE(sized["previewUrl"].toString() == "https://images.test/explicit.png"); + REQUIRE(sized["sourceLabel"].toString() == "Steam collection"); +} + +TEST_CASE( + "workshop collection version declarations override conflicting member " + "summary") { + CatalogCollection value; + value.no_common_version = true; + CardView noCommon(value, context(missing, "1.6")); + REQUIRE(noCommon["compatLabel"].toString() == "No common version"); + REQUIRE(noCommon["incompatible"].toBool()); + value.no_common_version = false; + CardView common(value, context(missing, "1.6")); + REQUIRE(common["compatLabel"].toString() == "Version not stated"); + REQUIRE_FALSE(common["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; + CardView view(value, context(missing, "1.6")); + REQUIRE_FALSE(view["incompatible"].toBool()); + REQUIRE(view["compatLabel"].toString() == "Works with 1.6"); + REQUIRE(view["versions"].toString().contains("1.6")); + REQUIRE(view["tags"].toString() == "Mod"); + } +} diff --git a/tests/ui/test_workshop_view.cpp b/tests/ui/test_workshop_view.cpp new file mode 100644 index 00000000..591513d4 --- /dev/null +++ b/tests/ui/test_workshop_view.cpp @@ -0,0 +1,541 @@ +#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 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) + +QVariant card(const CatalogListModel& model, const char* name, int row = 0) { + const auto names = model.roleNames(); + for (auto it = names.cbegin(); it != names.cend(); ++it) + if (it.value() == name) return model.data(model.index(row), it.key()); + FAIL("Missing role " << name); + return {}; +} + +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()}; + ConfigService config{dir.path()}; + WorkshopCatalog catalog{config, 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"))); + const auto& model = *f.view->collectionsModel; + CHECK(card(model, "actionLabel").toString() == + QStringLiteral("Complete download")); + CHECK(card(model, "state").toString() == QStringLiteral("missing")); + f.install(QStringLiteral("2")); + CHECK(card(model, "actionLabel").toString() == QStringLiteral("Installed")); + CHECK(card(model, "state").toString() == QStringLiteral("installed")); + f.downloader.active = {QStringLiteral("1")}; + f.core->downloads().busyChanged.notify(true); + CHECK(card(model, "queued").toBool()); + CHECK(card(model, "actionLabel").toString() == 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(card(*f.view->modsModel, "active").toBool()); + CHECK(card(*f.view->membersModel, "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(card(*f.view->collectionsModel, "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 64967843..00000000 --- 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" }, -]